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

Source page: https://appointme.dev/docs/concepts/messaging/

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

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

*Figure 1. An event goes out only if the write that raised it commits.*

1. **Aggregate** — `Raise(event)` adds it to `Events`

2. **EF Core save** — Wolverine scrapes `Events` into the outbox

3. **Durable local queue** — one per handler, stored in SQL Server

4. Then, in parallel:

   * **Booking** — keeps its copies of companies, staff and customers
   * **Identity** — creates invited users in the identity provider
   * **DemoSeedingSaga** — seeds a new demo company

| 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

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

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

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

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

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

## Wolverine configuration

**Everything is set in `AddWolverine`, in `WolverineHostBuilderExtensions.cs`.**

src/AppointMe.Api/Wolverine/WolverineHostBuilderExtensions.cs

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

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

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](https://wolverinefx.net/guide/handlers/error-handling.html). 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

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:

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

## 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()`. |

src/AppointMe.Api/Demo/DemoSeedingSaga.cs

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

## 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](/docs/guides/cross-module-events/) guide builds one end to end.

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

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?

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.

## Next

* [Cross-module events](/docs/guides/cross-module-events/) (Guide)
* [Background jobs](/docs/concepts/background-jobs/) (Concept)
* [Multi-tenancy](/docs/concepts/multi-tenancy/) (Concept)
