Skip to content
StarLive demo →
ConceptChecked against main · 31 Oct 202610 min read

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 an AggregateRoot
You touch it when
One module must react to another

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.

AggregateRaise(event) adds it to Events
EF Core saveWolverine scrapes Events into the outbox
Durable local queueone per handler, stored in SQL Server
Bookingkeeps its copies of companies, staff and customers
Identitycreates invited users in the identity provider
DemoSeedingSagaseeds a new demo company
Figure 1. An event goes out only if the write that raised it commits.
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 in the aggregate, save in the handler, and handle in the other module with a plain class: there’s no publish call anywhere.

src/AppointMe.Shared/Domain/AggregateRoot.csView source ↗
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:

src/Organizations/AppointMe.Organizations/Employees/DeleteEmployee/DeleteEmployee.csView source ↗
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:

src/Booking/AppointMe.Booking/ServiceProviders/ReconcileServiceProviders/EmployeeDeletedEventHandler.csView source ↗
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.

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).

Everything is set in AddWolverine, in WolverineHostBuilderExtensions.cs.

src/AppointMe.Api/Wolverine/WolverineHostBuilderExtensions.csView source ↗
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).
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.

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.

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:

Terminal window
dotnet run --project src/AppointMe.Api/AppointMe.Api.csproj -c Release --no-build --no-launch-profile -- codegen test

CodeGenerationDetection.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.

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().
src/AppointMe.Api/Demo/DemoSeedingSaga.csView source ↗
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.

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.

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.