PMQ.Domain 1.0.4

dotnet add package PMQ.Domain --version 1.0.4
                    
NuGet\Install-Package PMQ.Domain -Version 1.0.4
                    
This command is intended to be used within the Package Manager Console in Visual Studio, as it uses the NuGet module's version of Install-Package.
<PackageReference Include="PMQ.Domain" Version="1.0.4" />
                    
For projects that support PackageReference, copy this XML node into the project file to reference the package.
<PackageVersion Include="PMQ.Domain" Version="1.0.4" />
                    
Directory.Packages.props
<PackageReference Include="PMQ.Domain" />
                    
Project file
For projects that support Central Package Management (CPM), copy this XML node into the solution Directory.Packages.props file to version the package.
paket add PMQ.Domain --version 1.0.4
                    
#r "nuget: PMQ.Domain, 1.0.4"
                    
#r directive can be used in F# Interactive and Polyglot Notebooks. Copy this into the interactive tool or source code of the script to reference the package.
#:package PMQ.Domain@1.0.4
                    
#:package directive can be used in C# file-based apps starting in .NET 10 preview 4. Copy this into a .cs file before any lines of code to reference the package.
#addin nuget:?package=PMQ.Domain&version=1.0.4
                    
Install as a Cake Addin
#tool nuget:?package=PMQ.Domain&version=1.0.4
                    
Install as a Cake Tool

PMQ.Domain

Domain-Driven Design building blocks for .NET: entities with identity-based equality, aggregate roots, value objects and domain events.

Validation is accumulated as notifications, never thrown as exceptions.

NuGet

🇧🇷 Leia em português

Installation

dotnet add package PMQ.Domain

Requires .NET 10. Depends on PMQ.Mediator (to dispatch domain events) and PMQ.Notifications (for Validatable).

Why no exceptions

Exceptions are for programming errors. A violated business rule is an expected outcome — and treating it as an exception is expensive and, worse, reports only the first failure. An entity that accumulates notifications returns all of them at once:

{
  "status": 422,
  "errors": [
    { "field": "Items", "message": "Item must be at most 200 characters." },
    { "field": "Items", "message": "Provide at least one item." }
  ]
}

Components

Type Role
Entity<TId> Own identity, identity-based equality, validation and domain events
IAggregateRoot Marks the boundary of transactional consistency
ValueObject Value-based equality, from the declared components
IDomainEvent / DomainEvent A fact that occurred, dispatchable by PMQ.Mediator
IHasDomainEvents Non-generic contract for infrastructure to collect events

Entity

public sealed class Order : Entity<Guid>, IAggregateRoot
{
    private readonly List<string> _items = [];

    private Order() { }                                   // ORM

    private Order(Guid id) : base(id) { }

    public IReadOnlyCollection<string> Items => _items;

    public static Order Create(IEnumerable<string>? items)
    {
        var order = new Order(Guid.CreateVersion7());

        foreach (var item in items ?? [])
            order.AddItem(item);

        if (order._items.Count == 0)
            order.AddNotification(nameof(Items), "Provide at least one item.");

        // An invalid aggregate does not announce a fact that never happened.
        if (order.IsValid)
            order.Raise(new OrderPlacedDomainEvent(order.Id));

        return order;
    }

    public void AddItem(string? item)
    {
        if (string.IsNullOrWhiteSpace(item))
        {
            AddNotification(nameof(Items), "Item cannot be empty.");
            return;
        }

        _items.Add(item.Trim());
    }
}

The handler inspects the aggregate and promotes its failures to the request context, using AddFrom from PMQ.Notifications:

var order = Order.Create(request.Items);

if (order.IsInvalid)
{
    notificationContext.AddFrom(order, NotificationType.BusinessRule);   // → HTTP 422
    return Guid.Empty;
}

await repository.AddAsync(order, cancellationToken);
await unitOfWork.SaveChangesAsync(cancellationToken);

Identity-based equality

Two instances with the same Id are the same entity, even if everything else differs. The comparison also checks the type: an Order and an Invoice sharing a Guid are not equal.

Value object

public sealed class Money : ValueObject
{
    public decimal Amount { get; }
    public string Currency { get; }

    public Money(decimal amount, string currency)
    {
        if (amount < 0)
            AddNotification(nameof(Amount), "Amount cannot be negative.");

        Amount = amount;
        Currency = currency;
    }

    protected override IEnumerable<object?> GetEqualityComponents()
    {
        yield return Amount;
        yield return Currency;
    }
}

A record already gives value equality for free. Prefer ValueObject when the type also needs to accumulate notifications, or when equality must ignore some of its members.

Domain events

public sealed record OrderPlacedDomainEvent(Guid OrderId) : DomainEvent;

DomainEvent already carries EventId (UUID v7, useful for idempotency) and OccurredOn in UTC. Since it inherits from INotification, it is dispatchable by PMQ.Mediator with no glue at all:

internal sealed class OrderPlacedHandler(ILogger<OrderPlacedHandler> logger)
    : INotificationHandler<OrderPlacedDomainEvent>
{
    public Task Handle(OrderPlacedDomainEvent notification, CancellationToken cancellationToken)
    {
        logger.LogInformation("Order {OrderId} created.", notification.OrderId);
        return Task.CompletedTask;
    }
}

Publishing after the commit

Events stay pending on the aggregate until the transaction commits. IHasDomainEvents lets you collect them without reflection — with EF Core:

public async Task<bool> SaveChangesAsync(CancellationToken cancellationToken)
{
    var entities = context.ChangeTracker
        .Entries<IHasDomainEvents>()
        .Where(entry => entry.Entity.DomainEvents.Count > 0)
        .Select(entry => entry.Entity)
        .ToList();

    var domainEvents = entities.SelectMany(entity => entity.DomainEvents).ToList();

    foreach (var entity in entities)
        entity.ClearDomainEvents();

    await context.SaveChangesAsync(cancellationToken);

    // The cast to object selects the publisher's dynamic-dispatch overload, which resolves
    // handlers by the event's concrete type. Without it, the generic overload would be
    // inferred as Publish<IDomainEvent> and no handler would ever be found.
    foreach (var domainEvent in domainEvents)
        await publisher.Publish((object)domainEvent, cancellationToken);

    return true;
}

Publishing after the commit is deliberate: a handler must never observe a fact the transaction ended up rolling back.

License

MIT

Product Compatible and additional computed target framework versions.
.NET net10.0 is compatible.  net10.0-android was computed.  net10.0-browser was computed.  net10.0-ios was computed.  net10.0-maccatalyst was computed.  net10.0-macos was computed.  net10.0-tvos was computed.  net10.0-windows was computed. 
Compatible target framework(s)
Included target framework(s) (in package)
Learn more about Target Frameworks and .NET Standard.

NuGet packages

This package is not used by any NuGet packages.

GitHub repositories

This package is not used by any popular GitHub repositories.

Version Downloads Last Updated
1.0.4 208 8/12/2026
1.0.3 133 8/10/2026
1.0.2 109 8/5/2026
1.0.1 210 8/5/2026
1.0.0 125 8/5/2026