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

Source page: https://appointme.dev/docs/guides/cross-module-events/

* **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

## 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](/docs/concepts/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

**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.cs

   ```cs
   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.cs

   ```cs
               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.

## 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.**

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

   src/Booking/AppointMe.Booking/AppointMe.Booking.csproj

   ```xml
     <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.cs

   ```cs
   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.cs

   ```cs
   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.cs

   ```cs
               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.cs

   ```cs
           var attendee = await dbContext.Attendees
               .IgnoreQueryFilters()
               .SingleOrDefaultAsync(attendee => attendee.Id == new AttendeeId(@event.CustomerId), cancellationToken);


           if (attendee is null || attendee.IsDeleted)
           {
               return;
           }
   ```

Your handler can raise events too

`Attendee.Create` raises `AttendeeProjected` from Booking’s contracts, and the consumer’s save publishes it through the same outbox. `DemoSeedingSaga` listens for it to seed each new attendee’s appointments. Chains like this need no extra wiring.

## 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`:

src/AppointMe.Api/Wolverine/WolverineHostBuilderExtensions.cs

```cs
                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)

```cs
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.cs

```cs
    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.cs

```cs
    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](/docs/concepts/multi-tenancy/#background-work-run-it-for-a-company).

## 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 `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.cs

```cs
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.cs

```cs
    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.cs

```cs
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](/docs/concepts/background-jobs/).

## 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:

src/Booking/AppointMe.Booking.Tests/Attendees/ReconcileAttendees/CustomerRegisteredEventHandlerTests.cs

```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](/docs/concepts/messaging/)                                                | `Success!`                                                            |

## 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?

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.

## Next

* [Domain events & messaging](/docs/concepts/messaging/) (Concept)
* [Background jobs](/docs/concepts/background-jobs/) (Concept)
* [Booking](/docs/modules/booking/) (Module)
