Events and messaging
The Starter uses two kinds of event, and the difference is what makes the domains independent without losing consistency.
| Domain event | Integration event | |
|---|---|---|
| Raised by | An entity (AddDomainEvent(...)) | A domain-event handler, through IIntegrationEventPublisher<T> |
| Declared in | The domain's .Models | Blouppy.Infrastructure.Messaging.Abstractions/<Domain>/ |
| Handled | In the same process, inside the same transaction | By any domain or host, after the commit, through RabbitMQ |
| Handler | IDomainEventHandler<T> | IIntegrationEventHandler<T> |
| Typical use | A consequence that must commit or roll back with the change | Telling 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);
}
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 inMessagingServiceOptions. - One schema per host for the outbox tables —
wolverinefor the API,wolverine_identityfor the identity host — built by the migration runner. - A consumer every replica must see — the real-time fan-out — is registered with
AddInstanceScopedConsumerand 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.