Domain events & messaging
AppointMe modules talk through domain events that Wolverine 6 delivers via a transactional outbox and durable local queues in SQL Server, with no message broker to run.
- Bus
- Wolverine 6.60
- Transport
- Durable local queues in SQL Server
- Events
IDomainEvent, raised on anAggregateRoot- You touch it when
- One module must react to another
How an event travels
Section titled “How an event travels”An aggregate records an event; Wolverine writes it to the outbox in the same transaction as the change, then hands it to each handler through its own durable queue.
Raise(event) adds it to EventsEvents into the outbox| Piece | Where | What it does |
|---|---|---|
AggregateRoot |
AppointMe.Shared/Domain |
An abstract record with Raise(IDomainEvent) and a public Events list. |
IDomainEvent |
AppointMe.Shared/Domain |
A marker interface. Every event implements it. |
PublishDomainEventsFromEntityFrameworkCore<AggregateRoot> |
AppointMe.Api/Wolverine |
Tells Wolverine to read Events from every tracked aggregate when the handler’s DbContext saves. |
UseEntityFrameworkCoreTransactions + AutoApplyTransactions |
Same | Wraps each handler that uses a DbContext in a transaction that includes the outbox. |
UseDurableLocalQueues |
Same | Persists local messages in SQL Server before handling them, so they survive a restart. |
DomainEventContextPolicy |
AppointMe.Api/Wolverine/HandlerContext |
Runs event handlers as SystemIdentity and SystemPrincipal. |
TenantContextPolicy |
Same | Restores ICurrentCompany from the envelope’s tenant id. |
Raise and handle an event
Section titled “Raise and handle an event”Raise in the aggregate, save in the handler, and handle in the other module with a plain class: there’s no publish call anywhere.
public abstract record AggregateRoot{ public void Raise(IDomainEvent @event) { Events.Add(@event); }
public readonly List<IDomainEvent> Events = [];}The aggregate’s own method raises it. Here an employee is soft-deleted:
public void Delete(){ employee.IsDeleted = true; employee.Raise(new EmployeeDeleted(employee.Id.Value, employee.CompanyId.Value));}DeleteEmployeeCommandHandler calls SaveChangesAsync, and Wolverine does the rest. The event record lives in AppointMe.Organizations.Contracts, which is the only project Booking references from Organizations. Booking’s handler is a class with a Handle method:
public class EmployeeDeletedEventHandler(BookingDbContext dbContext, ServiceProviderSynchronizer synchronizer){ public async Task Handle(EmployeeDeleted @event, CancellationToken cancellationToken) { var providerId = new ServiceProviderId(@event.EmployeeId);
await synchronizer.Apply(providerId, snapshot: null, cancellationToken);
await dbContext.SaveChangesAsync(cancellationToken); }}There’s no separate integration-event type. An event that other modules may consume is an IDomainEvent record in the publisher’s *.Contracts project, with primitive fields (Guid, string, dates) and at most Shared kernel types such as Role[]. Consumers rebuild value objects from them with the validating factories, such as PersonName.Create and Email.CreateOrNull, because the payload comes from another module.
Wolverine finds handlers in every assembly marked [assembly: WolverineModule], and each module’s Configuration/<Module>Module.cs carries that attribute.
Cross-module flows
Section titled “Cross-module flows”Most events keep Booking’s local copies of Organizations and CRM data up to date.
| Event | Raised in | Consumer | What it does |
|---|---|---|---|
CompanyRegistered |
Organizations, Companies/RegisterCompany |
Booking CompanyRegisteredEventHandler |
Upserts the BookingCompany (name, time zone). |
DemoSeedingSaga |
Starts seeding when Demo:Enabled is true. |
||
EmployeeRegistered |
Organizations, Employees/RegisterEmployee |
Booking EmployeeRegisteredEventHandler |
Creates a ServiceProvider if the employee has the Staff role. |
EmployeeRolesUpdated |
Organizations, Employees/UpdateEmployeeRoles |
Booking EmployeeRolesUpdatedEventHandler |
Creates, updates, restores or soft-deletes the ServiceProvider, depending on Staff. |
EmployeeDeleted |
Organizations, Employees/DeleteEmployee |
Booking EmployeeDeletedEventHandler |
Soft-deletes the ServiceProvider. |
EmployeeInvited |
Organizations, Invitations/InviteEmployee |
Identity EmployeeInvitedHandler |
Creates the user in the identity provider, which emails the invitation. |
EmployeeInvitationResent |
Organizations, Invitations/ResendInvitation |
Identity EmployeeInvitationResentHandler |
Sends the invitation email again. |
CompanyPermissionsChanged |
Organizations, update and reset permissions | Organizations CompanyPermissionsChangedEventHandler |
Drops the company’s cached permission overrides. |
CustomerRegisteredEvent, CustomerUpdatedEvent |
CRM, Customers/RegisterCustomer, Customers/UpdateCustomer |
Booking CustomerRegisteredEventHandler, CustomerUpdatedEventHandler |
Upserts the Attendee. |
CustomerDeletedEvent |
CRM, Customers/DeleteCustomer |
Booking CustomerDeletedEventHandler |
Soft-deletes the Attendee. |
ServiceProviderProjected |
Booking, ServiceProviders/CreateServiceProvider |
DemoSeedingSaga |
Triggers customer seeding, once. |
AttendeeProjected |
Booking, Attendees/CreateAttendee |
DemoSeedingSaga |
Seeds three appointments for the new attendee. |
Six events are raised but have no handler yet: AppointmentScheduledEvent, AppointmentRescheduledEvent, AppointmentCancelledEvent, EmployeeInvitationAccepted, EmployeeInvitationCancelled and Identity’s UserRegisteredEvent. Subscribing to one only takes a new handler class. If an event is lost, the hourly and nightly reconciliation jobs repair Booking’s copies (see Background jobs).
Wolverine configuration
Section titled “Wolverine configuration”Everything is set in AddWolverine, in WolverineHostBuilderExtensions.cs.
options.ServiceLocationPolicy = ServiceLocationPolicy.AlwaysAllowed;
options.Durability.MessageStorageSchemaName = "wolverine";options.MultipleHandlerBehavior = MultipleHandlerBehavior.Separated;options.Durability.MessageIdentity = MessageIdentity.IdAndDestination;
options.Policies.Add<TenantContextPolicy>();options.Policies.Add<DomainEventContextPolicy>();options.Policies.Add<IdentityContextPolicy>();options.Policies.Add<PrincipalContextPolicy>();options.Policies.Add<CompanyContextPolicy>();
options.UseEntityFrameworkCoreTransactions();options.PublishDomainEventsFromEntityFrameworkCore<AggregateRoot>(root => root.Events);options.Policies.UseDurableLocalQueues();options.Policies.AutoApplyTransactions();
options.OnException<ValidationException>().MoveToErrorQueue();| Setting | Effect |
|---|---|
PersistMessagesWithSqlServer(AppointMeSql), plus MessageStorageSchemaName = "wolverine" |
The inbox, outbox and dead letters live in the app’s own database, in wolverine_* tables. At 2ccbe87 those tables are created in dbo, not wolverine: PersistMessagesWithSqlServer takes its own schema argument, which defaults to dbo. |
MultipleHandlerBehavior.Separated |
Each handler of a message type runs on its own, so a failing Booking handler doesn’t hold up the saga, and the other way round. |
MessageIdentity.IdAndDestination |
The inbox treats one message arriving at two handlers as two messages, not a duplicate. |
TypeLoadMode |
Dynamic in Development (handler code generated at runtime), Static elsewhere (pre-generated). |
Transport
Section titled “Transport”Wolverine:Transport |
What it does | Used by |
|---|---|---|
SqlDurable (the default) |
Durable local queues on top of the SQL outbox, with no broker | Local development and the Azure deployment |
AzureServiceBus |
Needs ConnectionStrings:AppointMeMessaging, then calls UseAzureServiceBus(...).AutoProvision() |
No shipped environment. The steps are in infra/README.md, “Messaging (Wolverine)”. |
The SQL outbox stays the durability layer either way. The Service Bus branch connects to the namespace but declares no routing rules, so check where your messages go before you rely on it.
Retries and dead letters
Section titled “Retries and dead letters”The one error rule moves a message whose handler throws ValidationException straight to the dead letters, because retrying invalid input can’t succeed. Other exceptions follow Wolverine’s default error handling. Dead letters go to wolverine_dead_letters in the wolverine schema, and AppointMe has no screen for them.
These rules cover queued messages only. Endpoints call bus.InvokeAsync and jobs call bus.InvokeForCompany (a wrapper over Wolverine’s InvokeForTenantAsync), which run the handler inline and throw straight back to the caller.
Handler code generation
Section titled “Handler code generation”Wolverine turns each handler chain, including the identity, principal and company middleware, into generated C#. Three places use it:
| Where | What runs | Why |
|---|---|---|
| Development | Nothing extra | TypeLoadMode.Dynamic generates and compiles handler code in memory at runtime. |
CI, the “Build and test” job in .github/workflows/devtest.yml |
codegen test |
Generates and compiles every chain, so a handler Wolverine can’t wire fails the job. |
src/AppointMe.Api/Dockerfile |
codegen write |
Writes src/AppointMe.Api/Internal/Generated/ (gitignored), which dotnet publish compiles into the image. |
Run the CI check locally after a Release build:
dotnet run --project src/AppointMe.Api/AppointMe.Api.csproj -c Release --no-build --no-launch-profile -- codegen testCodeGenerationDetection.IsRunningGeneration() spots the codegen argument. The host then boots in the Codegen environment with placeholder settings, Wolverine in MediatorOnly mode and no Hangfire, so it needs no database.
Sagas and the company on a message
Section titled “Sagas and the company on a message”One saga ships, DemoSeedingSaga. It’s keyed by company id and sends each command it starts with that company attached.
| Message | The saga |
|---|---|
CompanyRegistered |
Start: if Demo:Enabled, creates the saga with Id = CompanyId and expects 150 customers. Otherwise it returns null and no saga exists. |
ServiceProviderProjected |
The owner holds Staff, so a service provider appears. The first time, the saga returns SeedDemoCustomersCommand(150). |
AttendeeProjected |
Each seeded customer becomes an attendee. The saga returns SeedDemoAppointmentsCommand(attendeeId, 3), and after the 150th it calls MarkCompleted(). |
public object? Handle( [SagaIdentityFrom(nameof(ServiceProviderProjected.CompanyId))] ServiceProviderProjected @event){ if (CustomerSeedingTriggered) { return null; }
CustomerSeedingTriggered = true; return new SeedDemoCustomersCommand(CustomersExpected) .WithCompany(@event.CompanyId);}
public object Handle([SagaIdentityFrom(nameof(AttendeeProjected.CompanyId))] AttendeeProjected @event){ AttendeesSeeded++; if (AttendeesSeeded >= CustomersExpected) { MarkCompleted(); }
return new SeedDemoAppointmentsCommand(@event.AttendeeId, AppointmentsPerAttendee) .WithCompany(@event.CompanyId);}[SagaIdentityFrom(...)] maps an event field to the saga id. The saga lives in the API host, AppointMe.Api/Demo, because it drives commands in both CRM and Booking.
.WithCompany(...) puts the company in the envelope’s tenant id, and TenantContextPolicy restores it before the receiving handler runs. Handlers that take CompanyId also stamp it on the messages they cascade. The details are in Multi-tenancy.
Extend it
Section titled “Extend it”| Step | Where | What |
|---|---|---|
| 1. Define the event | <Module>.Contracts/<Feature>/Events/ |
public sealed record InvoicePaid(Guid InvoiceId, Guid CompanyId) : IDomainEvent; with primitive fields only |
| 2. Raise it | The aggregate’s method | invoice.Raise(new InvoicePaid(...)). The aggregate must inherit AggregateRoot. |
| 3. Save | The command handler | SaveChangesAsync. There’s no publish call. |
| 4. Reference | The consuming module’s .csproj |
The publisher’s *.Contracts project, never the module itself |
| 5. Handle | A slice in the consuming module | A class with Handle(InvoicePaid @event, CancellationToken cancellationToken). Rebuild value objects with their Create factories, and make it safe to run twice. |
| 6. Check | Your terminal | codegen test, as above |
The Cross-module events guide builds one end to end.
Gotchas
Section titled “Gotchas”| Symptom | Cause | Fix |
|---|---|---|
principal.Require(...) always passes in an event handler |
DomainEventContextPolicy runs event handlers as SystemPrincipal |
Check permissions in the command handler that raised the event. |
| An event is raised but never handled | The aggregate wasn’t tracked by the DbContext that saved, or the handler’s assembly lacks [assembly: WolverineModule] |
Load or add the aggregate through the handler’s DbContext, and check the attribute. |
| A handler runs twice for one event | Delivery is at least once: a retry after a crash runs it again | Make handlers idempotent. Booking’s handlers upsert, and its delete handlers return early when the row is already deleted. |
A saga message fails with UnknownSagaException |
A saga Handle method expects an existing saga, and DemoSeedingSaga only exists while a demo company seeds |
Add a static NotFound(...) method for that message to your saga. |
codegen test fails in CI but the app runs locally |
codegen test generates and compiles every chain, including ones you never ran locally |
Run the command above before you push. |
Why durable local queues instead of a broker?2 min · optional
A modular monolith runs every module in one process, so a broker would only carry messages from the process to itself. The SQL transport gives the property that matters: a message survives a restart or a deploy, and it commits with the data that caused it. You need no Service Bus emulator locally, and the Azure deployment dropped Service Bus in v1.1.0 to save cost.
The trade-off is that the database carries the queue load, and every instance works against the same tables. When a module moves to its own service, switch Wolverine:Transport and add routing. The handlers don’t change.
Why one event type, not separate domain and integration events?1 min · optional
Inside one process, a mapping layer between a domain event and an “integration” copy of it would be ceremony. The *.Contracts project is the boundary: what’s in it is public, and everything else in the module is private.
The cost is that a change to a contract record is a breaking change for every consumer, including messages already waiting in the queue. Add fields rather than rename them.