Cross-module events
Publish an event from one AppointMe module's contracts and handle it in another, with a Wolverine outbox, the company carried on the message, idempotent handlers and nightly reconciliation.
- You'll build
- A handler for another module’s event
- Time
- About 20 minutes
- Touches
- Publisher’s contracts, consumer’s
.csprojand slice - Before you start
- Two modules, like CRM and Booking
The pair to copy
Section titled “The pair to copy”CRM publishes customer events from its contracts project. Booking handles them and keeps its own Attendee copy of each customer.
That pair is the template for every cross-module flow in the repo, so this guide walks through it file by file. The theory, Wolverine’s configuration and the full list of flows are on Domain events & messaging.
| Piece | CRM to Booking | Lives in |
|---|---|---|
| Contract events | CustomerRegisteredEvent, CustomerUpdatedEvent, CustomerDeletedEvent |
AppointMe.Crm.Contracts/Customers/Events |
| Where they’re raised | Customer.Register, Update, Delete |
AppointMe.Crm/Customers/<Slice>/ |
| Handlers | CustomerRegisteredEventHandler, CustomerUpdatedEventHandler, CustomerDeletedEventHandler |
AppointMe.Booking/Attendees/ReconcileAttendees |
| Idempotent write | UpsertAttendee.UpsertAsync, shared by the handlers and reconciliation |
Same folder |
| Read-back contract | ICustomerRehydrationSource and CustomerSnapshot |
AppointMe.Crm.Contracts/Customers |
| Reconciliation | ReconcileAttendeesCommand, run daily by AttendeeReconciliationJob |
AppointMe.Booking/Attendees/ReconcileAttendees |
Publish the event
Section titled “Publish the event”Put the record in the publisher’s contracts, raise it from the aggregate, and save. There’s no publish call.
-
Define the event in
<Module>.Contracts/<Feature>/Events/. Use primitive fields, and always include the company:src/CRM/AppointMe.Crm.Contracts/Customers/Events/CustomerRegisteredEvent.csView source ↗ public sealed record CustomerRegisteredEvent(Guid CustomerId,Guid CompanyId,string FirstName,string? LastName,DateOnly? DateOfBirth,string? Email) : IDomainEvent;Guidandstringrather thanCustomerIdandPersonNamekeep the consumer free of the publisher’s types. The record is a contract, so add fields rather than renaming them: messages already in the queue were serialized with the old shape. -
Raise it in the domain operation.
AggregateRoot.Raiseonly adds the event to the aggregate’sEventslist:src/CRM/AppointMe.Crm/Customers/RegisterCustomer/RegisterCustomer.csView source ↗ customer.Raise(new CustomerRegisteredEvent(customer.Id.Value, companyId.Value,name.FirstName, name.LastName, dateOfBirth?.Value, email?.Value)); -
Save it in a handler that takes
CompanyId.RegisterCustomerCommandHandleradds the customer and callsSaveChangesAsync, and Wolverine’s EF Core integration writes every trackedAggregateRoot’sEventsto the outbox in the same transaction. TakingCompanyIdalso stamps the company on the outgoing message, as the next section explains.
Handle it in another module
Section titled “Handle it in another module”Reference the publisher’s contracts, add a plain handler class to the slice that does the work, and make it safe to run twice.
-
Reference the contracts project, never the module. Booking sees CRM’s events but not
CustomerorCrmDbContext:src/Booking/AppointMe.Booking/AppointMe.Booking.csprojView source ↗ <ItemGroup><ProjectReference Include="..\..\AppointMe.Shared\AppointMe.Shared.csproj" /><ProjectReference Include="..\..\CRM\AppointMe.Crm.Contracts\AppointMe.Crm.Contracts.csproj" /><ProjectReference Include="..\..\Organizations\AppointMe.Organizations.Contracts\AppointMe.Organizations.Contracts.csproj" /><ProjectReference Include="..\AppointMe.Booking.Contracts\AppointMe.Booking.Contracts.csproj" /></ItemGroup> -
Write the handler. A class whose name ends in
Handler, with aHandlemethod that takes the event, is all Wolverine needs. The module’s[assembly: WolverineModule]gets it discovered.src/Booking/AppointMe.Booking/Attendees/ReconcileAttendees/CustomerRegisteredEventHandler.csView source ↗ public class CustomerRegisteredEventHandler(BookingDbContext dbContext, AttendeeSynchronizer synchronizer){public async Task Handle(CustomerRegisteredEvent @event, CancellationToken cancellationToken){var snapshot = new CustomerSnapshot(CustomerId: @event.CustomerId,CompanyId: @event.CompanyId,FirstName: @event.FirstName,LastName: @event.LastName,DateOfBirth: @event.DateOfBirth,Email: @event.Email);await synchronizer.Apply(snapshot, cancellationToken);await dbContext.SaveChangesAsync(cancellationToken);}}Put it in the slice whose operation it calls, not in a shared
Handlers/folder. The handler maps the event to the sameCustomerSnapshotthat reconciliation uses, so both paths share one write. -
Rebuild value objects with their validating factories. The payload comes from another module, so treat it as untrusted input:
src/Booking/AppointMe.Booking/Attendees/ReconcileAttendees/AttendeeSynchronizer.csView source ↗ public sealed class AttendeeSynchronizer(BookingDbContext dbContext, TimeProvider timeProvider){public async Task Apply(CustomerSnapshot snapshot, CancellationToken cancellationToken){await dbContext.Attendees.UpsertAsync(customerId: snapshot.CustomerId,companyId: snapshot.CompanyId,name: PersonName.Create(snapshot.FirstName, snapshot.LastName),dateOfBirth: DateOfBirth.CreateOrNull(snapshot.DateOfBirth, timeProvider),email: Email.CreateOrNull(snapshot.Email),cancellationToken: cancellationToken);}}A
ValidationExceptionhere isn’t retried: the one error rule inAddWolverinemoves the message straight to the dead letters. -
Make the write idempotent. Key the local row on the source ID and upsert. The attendee’s ID is the customer’s ID:
src/Booking/AppointMe.Booking/Attendees/ReconcileAttendees/UpsertAttendee.csView source ↗ var attendee = await attendees.IgnoreQueryFilters().SingleOrDefaultAsync(attendee => attendee.Id == new AttendeeId(customerId), cancellationToken);if (attendee is null){attendee = Attendee.Create(id: new AttendeeId(customerId),companyId: new CompanyId(companyId),name: name,dateOfBirth: dateOfBirth,email: email);await attendees.AddAsync(attendee, cancellationToken);}else{attendee.Update(name, dateOfBirth, email);}A second delivery updates the row it created. An update that arrives before its registration creates the row. Deletes return early when the row is already gone or deleted:
src/Booking/AppointMe.Booking/Attendees/ReconcileAttendees/CustomerDeletedEventHandler.csView source ↗ var attendee = await dbContext.Attendees.IgnoreQueryFilters().SingleOrDefaultAsync(attendee => attendee.Id == new AttendeeId(@event.CustomerId), cancellationToken);if (attendee is null || attendee.IsDeleted){return;}
Durability and the company
Section titled “Durability and the company”The publisher’s save and its outgoing events commit in one SQL transaction. The consumer runs later, from a durable queue, in the publisher’s company.
All of it is set once in AddWolverine:
options.UseEntityFrameworkCoreTransactions(); options.PublishDomainEventsFromEntityFrameworkCore<AggregateRoot>(root => root.Events); options.Policies.UseDurableLocalQueues(); options.Policies.AutoApplyTransactions();
options.OnException<ValidationException>().MoveToErrorQueue();dotnet run --project src/AppointMe.Api --no-build -- codegen preview prints the code Wolverine generates for each handler. For CustomerRegisteredEventHandler it starts like this:
await AppointMe.Api.Wolverine.HandlerContext.SystemContextBehavior.Apply(_currentIdentity, _currentPrincipal).ConfigureAwait(false);await AppointMe.Api.Wolverine.HandlerContext.TenantContextBehavior.Apply(_currentCompany, context.Envelope).ConfigureAwait(false);
// Enroll the DbContext & IMessagingContext in the outgoing Wolverine outbox transactionvar efCoreEnvelopeTransaction = new Wolverine.EntityFrameworkCore.Internals.EfCoreEnvelopeTransaction(bookingDbContext, context, _domainEventScraperIEnumerable);await context.EnlistInOutboxAsync(efCoreEnvelopeTransaction).ConfigureAwait(false);// … await customerRegisteredEventHandler.Handle(customerRegisteredEvent, cancellation).ConfigureAwait(false);// … var result_of_SaveChangesAsync = await bookingDbContext.SaveChangesAsync(cancellation).ConfigureAwait(false);| Concern | What happens | Set by |
|---|---|---|
| The event is lost if the process dies | No. It’s written to Wolverine’s wolverine_* tables in the same database transaction as the publisher’s rows, and handled from a durable local queue. |
UseEntityFrameworkCoreTransactions, AutoApplyTransactions, UseDurableLocalQueues |
| Each consumer is independent | Every handler of an event gets its own copy and its own queue, so one failing consumer doesn’t block another | MultipleHandlerBehavior.Separated |
| Who the handler runs as | SystemIdentity and SystemPrincipal, so principal.Require(...) always passes. Check permissions in the publisher’s command handler. |
DomainEventContextPolicy |
| Which company the handler runs in | The publisher’s. CompanyContextBehavior writes it to the envelope’s tenant id, and TenantContextBehavior restores ICurrentCompany from it. |
CompanyContextPolicy, TenantContextPolicy |
The two behaviors are short. The publishing side:
public static CompanyId Load( ICurrentCompany currentCompany, IMessageContext messageContext) { var companyId = currentCompany.CompanyId ?? throw new AccessDeniedException("Active company was not specified."); messageContext.TenantId = companyId.Value.ToString(); return companyId; }The consuming side:
public static Task Apply(ICurrentCompany currentCompany, Envelope envelope) { if (string.IsNullOrEmpty(envelope.TenantId) || envelope.TenantId == DefaultTenantSentinel) { return Task.CompletedTask; }
if (!Guid.TryParse(envelope.TenantId, out var tenantId)) { throw new InvalidOperationException($"Envelope has a non-GUID TenantId '{envelope.TenantId}'."); }
currentCompany.Change(new CompanyId(tenantId)); return Task.CompletedTask; }An event saved by a handler that has no CompanyId parameter, and wasn’t itself sent for a company, goes out with no tenant id, and its consumers run with no company.
That’s why every contract event also carries CompanyId in its payload, and why Booking’s handlers use IgnoreQueryFilters() with explicit IDs rather than relying on the filter. More on tenants: Multi-tenancy.
Reconcile what events miss
Section titled “Reconcile what events miss”A projection hears only about changes that happen after it’s listening, so each one has a Reconcile* slice that re-reads the source and upserts.
Three things leave a projection out of date, and retrying fixes none of them:
- You deploy a new consumer while rows already exist, and events aren’t replayed.
- A message hits
ValidationExceptionand goes to the dead letters. - Someone edits the source table by hand or restores a backup.
Reconciliation handles all three by reading the source through an interface in the publisher’s contracts:
public interface ICustomerRehydrationSource{ Task<PagedResult<CustomerSnapshot>> GetByCompanyAsync( Guid companyId, PaginationFilter pagination, CancellationToken cancellationToken);}CRM implements it and registers it in AddCrmModule(). Booking’s handler pages through it and applies each snapshot through the same AttendeeSynchronizer the event handlers use. One bad record is logged and skipped, not allowed to stop the run:
private async Task ReconcileAttendee(CustomerSnapshot snapshot, CompanyId companyId, CancellationToken cancellationToken) { try { await synchronizer.Apply(snapshot, cancellationToken); await dbContext.SaveChangesAsync(cancellationToken); } catch (Exception exception) when (exception is not OperationCanceledException) { logger.LogError(exception, "Failed to reconcile attendee {CustomerId} in company {CompanyId}.", snapshot.CustomerId, companyId);
dbContext.ChangeTracker.Clear(); } }A Hangfire job sends the command once per company, so the handler gets its CompanyId like any other:
public sealed class AttendeeReconciliationJob(BookingDbContext dbContext, IMessageBus bus){ public async Task Run(CancellationToken cancellationToken) { var companies = await dbContext.BookingCompanies.ToListAsync(cancellationToken); foreach (var company in companies) { await bus.InvokeForCompany(company.Id, new ReconcileAttendeesCommand(), cancellationToken); } }}| Projection | Source contract | Job ID | Schedule | Handles deletes |
|---|---|---|---|---|
Attendee |
ICustomerRehydrationSource |
booking:attendee-reconciliation |
0 3 * * *, daily |
No: deleted customers aren’t returned, so a lost CustomerDeletedEvent stays lost |
BookingCompany |
ICompanyRehydrationSource |
booking:company-reconciliation |
0 * * * *, hourly |
Not needed: Organizations has no company delete |
ServiceProvider |
IEmployeeRehydrationSource |
booking:service-provider-reconciliation |
0 * * * *, hourly |
Yes: a full outer join of local rows and snapshots |
For your own projection, copy the four files: the command, its handler, the job and an IRecurringJobRegistrar. Register the job class in Add*Module() and call .AddRecurringJobs(ModuleAssembly.Instance). Jobs are covered on Background jobs.
Test and verify
Section titled “Test and verify”Handlers are plain classes, so test them directly against EF Core’s in-memory provider. Deliver the same event twice and assert one row.
This test was added to AppointMe.Booking.Tests, which already references Microsoft.EntityFrameworkCore.InMemory, and it passes:
// … usings: ReconcileAttendees, Booking.Database, Crm.Contracts.Customers.Events, EF Core, Time.Testingpublic class CustomerRegisteredEventHandlerTests{ [Fact] public async Task should_project_the_customer_once_when_the_event_is_delivered_twice() { var options = new DbContextOptionsBuilder<BookingDbContext>() // The rowversion column is required, and the in-memory provider never generates it. .UseInMemoryDatabase(Guid.NewGuid().ToString(), inMemory => inMemory.EnableNullChecks(false)) .Options; await using var dbContext = new BookingDbContext(options, new CurrentCompany()); var handler = new CustomerRegisteredEventHandler(dbContext, new AttendeeSynchronizer(dbContext, new FakeTimeProvider())); var @event = new CustomerRegisteredEvent(NewId(), NewId(), "Jane", "Doe", null, "jane@example.com");
await handler.Handle(@event, CancellationToken.None); await handler.Handle(@event, CancellationToken.None);
var attendee = await dbContext.Attendees.IgnoreQueryFilters().SingleAsync(); Assert.Equal(new AttendeeId(@event.CustomerId), attendee.Id); Assert.Equal(new CompanyId(@event.CompanyId), attendee.CompanyId); }}| Check | Command | Expect |
|---|---|---|
| The handler is idempotent | dotnet test src/Booking/AppointMe.Booking.Tests --filter "FullyQualifiedName~CustomerRegisteredEventHandlerTests" |
Passed! |
| The publisher raises the event | A factory test, like RegisterCustomerTests in AppointMe.Crm.Tests |
customer.Events.OfType<CustomerRegisteredEvent>().Single() succeeds |
| Wolverine found your handler | dotnet run --project src/AppointMe.Api --no-build -- codegen preview |
Your handler’s class name appears in the output |
| Every chain compiles | The codegen test command on Messaging |
Success! |
Troubleshooting
Section titled “Troubleshooting”Most cross-module bugs come from a missing reference, a missing company or a handler that isn’t idempotent.
| Symptom | Cause | Fix |
|---|---|---|
Required properties '{'Version'}' are missing for the instance of entity type 'Attendee' in a test |
The in-memory provider doesn’t generate rowversion values |
UseInMemoryDatabase(name, inMemory => inMemory.EnableNullChecks(false)) |
| The handler compiles but never runs | Its assembly has no [assembly: WolverineModule], or the aggregate was saved by a DbContext that didn’t track it |
Check codegen preview and the publisher’s save |
AccessDeniedException: Active company was not specified. in a consumer’s log |
The consumer takes CompanyId, but the event went out with no tenant id |
Save the event from a handler that takes CompanyId, or send it with WithCompany(...) |
| Duplicate projected rows | The handler inserts instead of upserting | Key the projection on the source ID and upsert, like UpsertAttendee |
| A projection is missing rows that existed before you deployed it | Events aren’t replayed for a new consumer | Add a Reconcile* command and job, then run it once from the Hangfire dashboard at /admin/jobs |
A message sits in wolverine_dead_letters |
The handler threw ValidationException |
Fix the data. Reconciliation repairs the projection on its next run. |
Why reconcile at all, when the outbox already guarantees delivery?2 min · optional
The outbox guarantees that a committed change produces a message, and that the message reaches a handler at least once. It can’t help with data that existed before the handler did, with a message that’s correct but fails validation every time, or with a row changed outside the application.
A periodic read of the source through a contract interface catches all of these with one mechanism, and it reuses the handler’s own write path. The cost is a full scan per company on each run, and some lag: until the next run, a missed change stays missed.