Events and messaging

The Starter uses two kinds of event, and the difference is what makes the domains independent without losing consistency.

Domain eventIntegration event
Raised byAn entity (AddDomainEvent(...))A domain-event handler, through IIntegrationEventPublisher<T>
Declared inThe domain's .ModelsBlouppy.Infrastructure.Messaging.Abstractions/<Domain>/
HandledIn the same process, inside the same transactionBy any domain or host, after the commit, through RabbitMQ
HandlerIDomainEventHandler<T>IIntegrationEventHandler<T>
Typical useA consequence that must commit or roll back with the changeTelling another domain, a module or another host that something happened

Domain events: inside the transaction

An entity records what happened; nothing is dispatched yet. When the controller calls IUnitOfWork.CommitAsync, an EF Core interceptor (PublishDomainEventsInterceptor) dispatches the events beforeSaveChanges, so the handlers' own writes join the same transaction.

Creating a workspace raises WorkspaceCreatedDomainEvent, and CreateTeamWhenWorkspaceCreatedDomainEventHandler creates the workspace's team: the two rows commit together, or neither does.

Integration events: across boundaries, through the outbox

A domain does not call another to tell it something happened. It publishes an integration event — from a domain-event handler, so it is part of the same commit:

// Blouppy.Organizations — announces a declined invitation, for the audit log.
internal sealed class PublishIntegrationEventWhenOrganizationInvitationDeclinedDomainEventHandler(
    IIntegrationEventPublisher<OrganizationInvitationDeclinedIntegrationEvent> integrationEventPublisher,
    ICurrentUserService currentUserService)
    : IDomainEventHandler<OrganizationInvitationDeclinedDomainEvent>
{
    public async Task HandleAsync(OrganizationInvitationDeclinedDomainEvent @event, CancellationToken cancellationToken)
        => await integrationEventPublisher.PublishAsync(
            new OrganizationInvitationDeclinedIntegrationEvent(/* … */, AuditActor.Of(currentUserService), /* … */),
            cancellationToken);
}
Handler in another domainRabbitMQPostgreSQLUnit of workDomain serviceHandler in another domainRabbitMQPostgreSQLUnit of workDomain serviceentity changes + domain eventdomain-event handlers run, publish the integration eventone transaction: the change + the outbox rowcommittedthe outbox is flusheddelivered, retried on failure
Handler in another domainRabbitMQPostgreSQLUnit of workDomain serviceHandler in another domainRabbitMQPostgreSQLUnit of workDomain serviceentity changes + domain eventdomain-event handlers run, publish the integration eventone transaction: the change + the outbox rowcommittedthe outbox is flusheddelivered, retried on failure

The transactional outbox is what makes this safe. The message is written to PostgreSQL in the same transaction as the change, and sent only once that transaction commits:

  • the change rolls back → the message is never sent;
  • the broker is down at commit time → the row stays, and Wolverine sends it later.

An event therefore exists if and only if the change did — the audit log relies on exactly that.

Delivery

  • One fanout exchange per event type, and one durable queue per consumer, named after it (blouppy.workspace-created). Several replicas of a service share the queue.
  • Retries: five, spaced 1 s, 6 s, 16 s, 36 s and 1 min, then the message goes to the dead-letter queue blouppy.dead-letters. The policy is in MessagingServiceOptions.
  • One schema per host for the outbox tables — wolverine for the API, wolverine_identity for the identity host — built by the migration runner.
  • A consumer every replica must see — the real-time fan-out — is registered with AddInstanceScopedConsumer and gets its own auto-deleted queue per process.

Wolverine stays in one project

Only Blouppy.Infrastructure.Messaging references Wolverine. Everything else uses the Starter's own interfaces — IIntegrationEventPublisher<T>, IIntegrationEventHandler<T>, IMessageConsumer<T>, IMessagingConfigurator — and BusLibraryBoundaryTests fails the build on any other reference. Changing the bus library means rewriting one adapter.

In tests, the bus runs on in-memory queues behind the same outbox (Messaging:Transport=InMemory); the end-to-end suite runs the real RabbitMQ.