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

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 .csproj and slice
Before you start
Two modules, like CRM and Booking

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

Put the record in the publisher’s contracts, raise it from the aggregate, and save. There’s no publish call.

  1. 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;

    Guid and string rather than CustomerId and PersonName keep 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.

  2. Raise it in the domain operation. AggregateRoot.Raise only adds the event to the aggregate’s Events list:

    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));
  3. Save it in a handler that takes CompanyId. RegisterCustomerCommandHandler adds the customer and calls SaveChangesAsync, and Wolverine’s EF Core integration writes every tracked AggregateRoot’s Events to the outbox in the same transaction. Taking CompanyId also stamps the company on the outgoing message, as the next section explains.

Reference the publisher’s contracts, add a plain handler class to the slice that does the work, and make it safe to run twice.

  1. Reference the contracts project, never the module. Booking sees CRM’s events but not Customer or CrmDbContext:

    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>
  2. Write the handler. A class whose name ends in Handler, with a Handle method 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 same CustomerSnapshot that reconciliation uses, so both paths share one write.

  3. 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 ValidationException here isn’t retried: the one error rule in AddWolverine moves the message straight to the dead letters.

  4. 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;
    }

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:

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

codegen preview output (excerpt)
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 transaction
var 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:

src/AppointMe.Api/Wolverine/HandlerContext/CompanyContextBehavior.csView source ↗
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:

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

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 ValidationException and 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:

src/CRM/AppointMe.Crm.Contracts/Customers/ICustomerRehydrationSource.csView source ↗
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:

src/Booking/AppointMe.Booking/Attendees/ReconcileAttendees/ReconcileAttendeesCommandHandler.csView source ↗
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:

src/Booking/AppointMe.Booking/Attendees/ReconcileAttendees/AttendeeReconciliationJob.csView source ↗
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.

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:

src/Booking/AppointMe.Booking.Tests/Attendees/ReconcileAttendees/CustomerRegisteredEventHandlerTests.cs
// … usings: ReconcileAttendees, Booking.Database, Crm.Contracts.Customers.Events, EF Core, Time.Testing
public 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!

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.