PouryaKit
Guides

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.

ProductCreatedV1.cs
/// <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.

OpenStockItemIntegrationEventHandler.cs
// 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:

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");
}

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

GuaranteeStated in
One durable delivery per subscription, applied once, a continuation of the changeWhat the Kit guarantees
What Publish refusesPublishing from a Notification Handler
Several Modules subscribing to one Integration EventDeclaring what a Module publishes and subscribes
Adding a new version, and retiring the old oneA new version of an Integration Event

Failure messages

Next steps

On this page