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:
// 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:
// 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:
[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
| Guarantee | Stated in |
|---|---|
| Stored in the commit | What the Kit guarantees |
| Sent after the commit | What the Kit guarantees |
| A continuation in its own scope | What the Kit guarantees |
| Each handler on its own | What the Kit guarantees |
| A database effect once, an outside effect at least once | What the Kit guarantees |
| How a failed delivery is retried, then set aside | Retries and Dead letters |
| How names are derived, and what a rename does | Names |