PouryaKit
Concepts

Domain Event

A fact an aggregate raises inside its Module, handled in the same commit as the change that raised it.

What it is

A Domain Event is a fact raised by an aggregate, visible only inside its Module. A Domain Event Handler is a reaction to it inside the same Module that takes part in the same Unit of Work: its changes commit with the change that raised the Domain Event, or not at all.

A Domain Event Handler runs before the commit

Use it to keep the Module's own aggregates consistent with each other. Anything that reaches outside the Module's own data, such as an email, a push to a browser or an Integration Event, belongs in a Notification Handler, which runs after the commit. Whatever a Domain Event Handler sent would stay sent when the Operation rolls back.

When to use it

When one aggregate's change must change another aggregate of the same Module in the same commit. In the Proving Sample, each new product is counted in its category, so a product and its category's count are saved together, or neither is.

One Domain Event may have both kinds of reaction: Catalog's ProductCreatedDomainEvent is counted by a Domain Event Handler in the product's commit, and announced and published by Notification Handlers after it: see Outbox and Notifications.

How it works

Declare the Domain Event

A sealed record deriving from DomainEvent, named in the past tense. It carries typed IDs and values, never the aggregate itself.

ProductCreatedDomainEvent.cs
internal sealed record ProductCreatedDomainEvent(
    ProductId ProductId,
    CategoryId CategoryId,
    string Name
) : DomainEvent;

Raise it from the aggregate

The aggregate calls Raise when the fact happens. Only the Unit of Work takes and clears Domain Events.

Product.cs
public static Result<Product> Create(
    ProductId id,
    CategoryId categoryId,
    string name,
    string tenant
)
{
    ArgumentNullException.ThrowIfNull(id);
    ArgumentNullException.ThrowIfNull(categoryId);
    ArgumentException.ThrowIfNullOrWhiteSpace(tenant);
    if (string.IsNullOrWhiteSpace(name))
    {
        return ProductErrors.NameRequired;
    }

    var product = new Product
    {
        Id = id,
        Tenant = tenant,
        CategoryId = categoryId,
        Name = name.Trim(),
    };
    product.Raise(new ProductCreatedDomainEvent(id, categoryId, product.Name));
    return product;
}

Handle it in the same Module

An internal sealed class implementing IDomainEventHandler<TDomainEvent>, beside the Business Area that reacts. It changes aggregates through a repository, saves nothing and returns a Result. AddPouryaKitCqrs registers it with the Module's other handlers.

CountProductInCategoryDomainEventHandler.cs
// Counts a new product in its category in the same transaction as the product: both are saved, or neither is.
internal sealed class CountProductInCategoryDomainEventHandler(
    IRepository<Category, CategoryId> categories
) : IDomainEventHandler<ProductCreatedDomainEvent>
{
    public async Task<Result> Handle(
        ProductCreatedDomainEvent domainEvent,
        CancellationToken cancellationToken
    )
    {
        var category = await categories.FindAsync(domainEvent.CategoryId, cancellationToken);
        if (category is null)
        {
            return CategoryErrors.NotFound;
        }

        category.CountProduct();
        return Result.Success();
    }
}

Test both outcomes

Send the command whose aggregate raises the Domain Event through the Gateway, and assert both aggregates' rows: written together on success, and neither written when the reaction fails.

DomainEventTests.cs
[Fact]
public async Task A_category_counts_a_new_product_in_the_same_commit_and_a_failed_count_leaves_no_product()
{
    var catalog = Fixture.Gateway<CatalogModule>();
    var lamps = Guid.CreateVersion7();
    await catalog.SendAsync(
        new CreateCategoryCommand(lamps, "Lamps"),
        TestContext.Current.CancellationToken
    );

    var created = await catalog.SendAsync(
        new CreateProductCommand(Guid.CreateVersion7(), lamps, "Desk lamp"),
        TestContext.Current.CancellationToken
    );
    var refused = await catalog.SendAsync(
        new CreateProductCommand(Guid.CreateVersion7(), Guid.CreateVersion7(), "Floor lamp"),
        TestContext.Current.CancellationToken
    );

    Assert.True(created.IsSuccess);
    Assert.Equal(CategoryErrors.NotFound, Assert.Single(refused.Errors));
    Assert.Equal(
        [
            [lamps, 1],
        ],
        await Fixture.RowsAsync("select id, product_count from catalog.categories")
    );
    Assert.Equal(
        ["Desk lamp"],
        (await Fixture.RowsAsync("select name from catalog.products")).Select(row => row[0])
    );
}

What the Kit guarantees

GuaranteeStated in
When a Domain Event Handler runs, in which order and in which transactionDomain Event Handlers
What a failed handler does to the OperationDomain Event Handlers
How a Domain Event is declared, raised and serializedDomain Events
When to choose a Notification Handler insteadNotification Handler or Domain Event Handler

Failure messages

Next steps

On this page