Publish and subscribe to an Integration Event
Tell other Modules what happened, with an Integration Event declared in Contracts, published from a Notification Handler and applied in each subscribing Module.
What you build
An Integration Event one Module publishes after a change commits, and an Integration Event Handler in each Module that reacts to it. This guide follows the Proving Sample: Catalog publishes ProductCreatedV1 for each new product, and Inventory opens a stock item for it. Integration Events explains the idea.
Publish from a Notification Handler only
The Integration Event describes a change that already committed, so it is published after the commit, from a Notification Handler of the Domain Event the change raised. A command handler or an Integration Event Handler that publishes throws.
When to use it
When another Module reacts to something that happened in this one. When it keeps its own copy of this Module's data, publish a Snapshot instead: see Snapshots and Tombstones.
Steps
Declare the Integration Event in the publishing Module's Contracts
A public sealed record named with its version, with plain values only and nothing from the Kit. Give it the Domain Event's identity and time, so every receiver sees what the publisher saw.
/// <summary>
/// A product was added to the catalog. <paramref name="Id"/> and <paramref name="OccurredOn"/> are the identity and the
/// time of the Domain Event it follows from, the same in every delivery. Catalog publishes it under the wire name
/// <c>catalog.product-created.v1</c>; a change other Modules could not read is a new version, not an edit of this one.
/// </summary>
public sealed record ProductCreatedV1(
Guid Id,
DateTimeOffset OccurredOn,
Guid ProductId,
string Name
);Declare the publication
In the publishing Module's Composition Root, declare the type under its wire name with Publish, as Catalog declares ProductCreatedV1. Put the version in the wire name, and change it only with a new version of the type: see Declaring what a Module publishes and subscribes and Names.
Publish it from a Notification Handler
Give publishing a Notification Handler of its own for the Domain Event, so a failing announcement neither holds back nor repeats it. It injects IIntegrationEventPublisher and calls Publish with the identity of the Domain Event it handles. Publishing sends nothing: the delivery's Unit of Work writes it to the Outbox, and it is sent after that commit. Publishing from a Notification Handler shows Catalog's PublishProductCreatedNotificationHandler.
Write the handler in the receiving Module
An internal sealed class implementing IIntegrationEventHandler<TIntegrationEvent>, beside the Feature it serves. It holds only the business effect: the Kit records its Receipt, commits and acknowledges.
// Starts keeping stock of every product Catalog creates, for the tenant the event was delivered for, which GetStockItem
// answers. Only the effect: the Kit records the Receipt, commits and acknowledges, so a redelivered or concurrently
// duplicated ProductCreatedV1 opens one stock item.
internal sealed class OpenStockItemIntegrationEventHandler(
IRepository<StockItem, StockItemId> stockItems,
OperationContext context
) : IIntegrationEventHandler<ProductCreatedV1>
{
public async Task<Result> Handle(
ProductCreatedV1 integrationEvent,
CancellationToken cancellationToken
)
{
await stockItems.AddAsync(
StockItem.Open(integrationEvent.ProductId, integrationEvent.OccurredOn, context.Tenant),
cancellationToken
);
return Result.Success();
}
}Subscribe it
In the receiving Module's Composition Root, subscribe the handler under a subscription name of its own with Subscribe, as Inventory subscribes OpenStockItemIntegrationEventHandler. The receiving project references the publishing Module's Contracts project, never the Module.
Give each Module its Receipt table
Both Modules need the Receipt table in their own schema: the publisher keeps its publication records there, and the receiver the Receipts of what it applied. Add each one's table with a migration, filled by hand since the model does not change:
// 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");
}Test the delivery
Run the command through the Gateway, wait for every delivery it caused, and assert each subscription's delivery and its effect. See Test a Feature.
The publishing Module may run in another Host. Then the receiving Host declares the wire name with Receive, both Hosts join a RabbitMQ broker, and neither Module changes: see RabbitMQ between Hosts.
What the Kit guarantees
| Guarantee | Stated in |
|---|---|
| One durable delivery per subscription, applied once, a continuation of the change | What the Kit guarantees |
What Publish refuses | Publishing from a Notification Handler |
| Several Modules subscribing to one Integration Event | Declaring what a Module publishes and subscribes |
| Adding a new version, and retiring the old one | A new version of an Integration Event |
Failure messages
Next steps
Make a command idempotent
Let a client repeat a request it never got an answer to, and replay the first success instead of running the command again.
Schedule a delayed or recurring command
Schedule one of a Module's commands to run later from the Operation that causes it, or declare one to run on a cadence as a system Actor.