PouryaKit
Concepts

Outbox and Notifications

A committed Domain Event waits in its Module's Outbox as a Notification, for the Notification Handlers that react to it outside the Module's own data.

What it is

When an Operation commits, each Domain Event it raised that a Notification Handler subscribes to is stored as a Notification: a Domain Event that has been committed and waits to be reacted to outside the Unit of Work that raised it. It is stored in the Module's Outbox, the Module's share of the Host's durable message store, in the same transaction as the change.

A Notification Handler reacts to it after the commit: it sends something, or publishes an Integration Event. Each handler is one subscription under a stable name, which names its own durable queue, so its deliveries are retried and set aside as dead letters on their own. Before the handler runs, its delivery records a Receipt, so a redelivered Notification is applied once. The Outbox's counterpart is the Inbox: the Integration Events and Snapshots a Module has received but not yet applied.

An effect outside the database happens at least once

The Receipt commits with what the handler wrote to its Module's database, so that effect happens once. An email or a call to another system cannot roll back with it: when an attempt fails after sending, the next attempt sends again. Make the repeat harmless where the other system allows it, and do the outside effect last: What the Kit guarantees.

When to use it

For anything that reaches outside the Module's own data and cannot be taken back: sending an email, pushing to a browser, calling another system, publishing an Integration Event. A change that must commit with the change that raised the Domain Event belongs in a Domain Event Handler: see Domain Event.

How it works

A Notification Handler implements INotificationHandler<TDomainEvent> for one Domain Event type. It reaches outside the Module through a port, an interface the Module declares and the Host implements, so it never names a transport. This one announces each new product through the port INewProductAnnouncer, then pushes it to the browsers of its tenant:

AnnounceNewProductNotificationHandler.cs
// Runs after the product is committed, never inside its transaction: an announcement of a product that was then rolled
// back could not be taken back. The browsers of the product's tenant get it when this delivery commits.
internal sealed class AnnounceNewProductNotificationHandler(
    INewProductAnnouncer announcer,
    IRealtimePublisher realtime
) : INotificationHandler<ProductCreatedDomainEvent>
{
    public async Task<Result> Handle(
        ProductCreatedDomainEvent domainEvent,
        CancellationToken cancellationToken
    )
    {
        await announcer.AnnounceAsync(
            new NewProductAnnouncement(domainEvent.ProductId.Value, domainEvent.Name),
            cancellationToken
        );
        return await realtime.PublishAsync(
            NewProduct.Channel,
            new NewProduct(domainEvent.ProductId.Value, domainEvent.Name),
            cancellationToken
        );
    }
}

The Composition Root declares the Module's Notification Handlers with AddPouryaKitNotificationHandlers(), and the Host runs the deliveries with UsePouryaKitWolverine, on Wolverine with its message store in PostgreSQL. Each handler gets a subscription name by convention, and keeps it: renaming a handler with deliveries in flight changes its name unless you declare the old one, as Names shows. The Module's Receipts live in a table its migration creates:

20261005180000_Receipts.cs
// The Receipts of Catalog's Notification Handlers. The table is not in the model, so it leaves the snapshot unchanged.
[DbContext(typeof(CatalogDbContext))]
[Migration("20261005180000_Receipts")]
internal sealed class Receipts : Migration
{
    protected override void Up(MigrationBuilder migrationBuilder) => migrationBuilder.CreateReceiptTable("catalog");

    protected override void Down(MigrationBuilder migrationBuilder) => migrationBuilder.DropReceiptTable("catalog");
}

A test runs the Operation and waits for every delivery it caused, those deliveries' own deliveries included:

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

    var (created, deliveries) = await Fixture.WaitForDeliveriesAsync(
        () =>
            catalog.SendAsync(
                new CreateProductCommand(product, category, "Lamp"),
                TestContext.Current.CancellationToken
            ),
        TestContext.Current.CancellationToken
    );

    Assert.True(created.IsSuccess);
    Assert.Equal(
        [
            new Delivery("billing.product-created", "catalog.product-created.v1", false),
            new Delivery(
                "catalog.product-created.announce-new-product",
                "catalog.product-created",
                false
            ),
            new Delivery(
                "catalog.product-created.publish-product-created",
                "catalog.product-created",
                false
            ),
            new Delivery("inventory.catalog-products", "catalog.product-snapshot.v1", false),
            new Delivery("inventory.product-categories", "catalog.product-created.v2", false),
            new Delivery("inventory.product-created", "catalog.product-created.v1", false),
        ],
        deliveries.OrderBy(delivery => delivery.Subscription, StringComparer.Ordinal)
    );
    Assert.Single(
        Fixture.Logs.Entries,
        entry => entry.Message == $"New in the catalog: Lamp ({product})"
    );
}

What the Kit guarantees

GuaranteeStated in
Stored in the commitWhat the Kit guarantees
Sent after the commitWhat the Kit guarantees
A continuation in its own scopeWhat the Kit guarantees
Each handler on its ownWhat the Kit guarantees
A database effect once, an outside effect at least onceWhat the Kit guarantees
How a failed delivery is retried, then set asideRetries and Dead letters
How names are derived, and what a rename doesNames

Failure messages

Next steps

On this page