# Modular monolith

> Four modules run in one .NET 10 process against one SQL Server database. Each owns its code, its EF Core DbContext and its schema, and the others reach it only through its Contracts project.

Source page: https://appointme.dev/docs/concepts/modular-monolith/

* **Modules:** Identity, Organizations, CRM, Booking
* **Runs as:** One process, `AppointMe.Api`
* **Data:** One database, one schema per module
* **Cross-module:** `.Contracts` events and interfaces

## A module is three projects

**Each module is a folder under `src/` with a contracts project, the module itself and its tests, and project references only point one way.**

*Figure 1. Booking references CRM's contracts project, never CRM itself.*

1. **AppointMe.Api** — the host: registers every module
2. **AppointMe.Booking** — slices, `BookingDbContext`, migrations
3. **AppointMe.Crm.Contracts** — events, snapshots, one interface
4. **AppointMe.Shared** — the shared kernel

| Project                        | Holds                                                                                                                                              | References                                          |
| ------------------------------ | -------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------- |
| `AppointMe.<Module>.Contracts` | What other modules may use: events (`CustomerRegisteredEvent`), snapshots (`CustomerSnapshot`) and query interfaces (`ICustomerRehydrationSource`) | `AppointMe.Shared` only                             |
| `AppointMe.<Module>`           | The [vertical slices](/docs/concepts/vertical-slices/), aggregates, `DbContext`, migrations and the `Add<Module>Module()` registration             | Shared, its own Contracts, other modules’ Contracts |
| `AppointMe.<Module>.Tests`     | Unit tests, in folders that mirror the slices                                                                                                      | The module project                                  |
| `AppointMe.Shared`             | Value objects, `CompanyId`, permissions, `IEndpoint`, Dapper helpers                                                                               | Nothing in the solution                             |
| `AppointMe.Api`                | `Program.cs`, middleware, Wolverine setup, error handling                                                                                          | Every module                                        |

The four modules form a one-way graph. Organizations depends on no other module, and Booking sits at the far end:

| Module        | Folder              | Uses the contracts of |
| ------------- | ------------------- | --------------------- |
| Organizations | `src/Organizations` | —                     |
| Identity      | `src/Identity`      | Organizations         |
| CRM           | `src/CRM`           | Organizations         |
| Booking       | `src/Booking`       | CRM, Organizations    |

## Each module owns a DbContext and a schema

**All four modules share one connection string, but each `DbContext` maps to its own SQL schema and keeps its own migrations history there.**

| Module        | DbContext                | Schema          |
| ------------- | ------------------------ | --------------- |
| Identity      | `IdentityDbContext`      | `identity`      |
| Organizations | `OrganizationsDbContext` | `organizations` |
| CRM           | `CrmDbContext`           | `crm`           |
| Booking       | `BookingDbContext`       | `booking`       |

src/CRM/AppointMe.Crm/Configuration/CrmModule.cs

```cs
.AddDbContext<CrmDbContext>((serviceProvider, options) =>
{
    options.UseSqlServer(serviceProvider.GetRequiredService<ConnectionStrings>().AppointMeSql,
        builder =>
        {
            builder.MigrationsHistoryTable("__EFMigrationsHistory", CrmDbContext.DefaultSchema);
        });
})
.AddDatabaseMigration<CrmDbContext>()
```

`CrmDbContext.OnModelCreating` calls `modelBuilder.HasDefaultSchema(DefaultSchema)`, and `DefaultSchema` is `"crm"`, so every CRM table lands in that schema. `AddDatabaseMigration<CrmDbContext>()` adds the context to a hosted service that runs `MigrateAsync` for each registered context at startup.

Each module keeps its migrations in its own `Database/Migrations` folder (see [Database migrations](/docs/guides/database-migrations/)). Wolverine’s `wolverine_*` outbox tables live in the same database. At this commit they’re created in `dbo`, although the code asks for a `wolverine` schema (see [Database migrations](/docs/guides/database-migrations/)).

## Registering a module

**The host calls one `Add*Module()` extension per module in `Program.cs`, and each extension registers everything its module needs.**

src/AppointMe.Api/Program.cs

```cs
builder.Services
    .AddSharedModule(builder.Configuration)
    .AddIdentityModule(builder.Configuration)
    .AddOrganizationsModule()
    .AddCrmModule()
    .AddBookingModule();
```

`AddIdentityModule` takes the configuration because it picks the identity provider from `Authentication:Provider`. Here’s what `AddCrmModule()` registers:

| Registration                                                            | What for                                                                                   |
| ----------------------------------------------------------------------- | ------------------------------------------------------------------------------------------ |
| `AddDbContext<CrmDbContext>` and `AddDatabaseMigration<CrmDbContext>()` | The write model and its startup migration                                                  |
| `AddEndpoints(ModuleAssembly.Instance)`                                 | Every `IEndpoint` in the assembly. See [API conventions](/docs/concepts/api-conventions/). |
| `AddSingleton<IDbConnectionFactory, SqlConnectionFactory>`              | Connections for the Dapper read side                                                       |
| `AddScoped<CustomersRepository>()`                                      | The module’s Dapper repository                                                             |
| `AddScoped<ICustomerRehydrationSource, CustomerRehydrationSource>()`    | CRM’s implementation of its own public contract                                            |
| `AddScoped<SeedDemoCustomers>()`, `AddScoped<TopUpDemoCustomersJob>()`  | Demo-mode customer seeding                                                                 |
| `AddPermissions(...)`, `AddRecurringJobs(...)`                          | Permission discovery and Hangfire jobs                                                     |

Wolverine handlers aren’t registered here. Every module assembly carries `[assembly: WolverineModule]`, which tells Wolverine to scan it for handlers. The contracts assemblies that define events carry it too.

## How modules talk: customers become attendees

**CRM publishes events from its contracts project. Booking handles them and keeps its own `Attendee` copy of each customer, so CRM never calls Booking.**

*Figure 2. One event, defined in CRM's contracts and handled in Booking.*

1. **CRM: Customer.Register** — raises `CustomerRegisteredEvent`

2. **SaveChangesAsync** — the event goes into the outbox in the same transaction

3. **Booking handler** — `CustomerRegisteredEventHandler` upserts an `Attendee`

4. Then, in parallel:

   * **CustomerUpdatedEvent** — updates the attendee
   * **CustomerDeletedEvent** — soft-deletes the attendee
   * **Daily at 03:00** — a job re-syncs from `ICustomerRehydrationSource`

The event is a record of plain values in `AppointMe.Crm.Contracts`:

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

CRM’s domain operation raises it on the aggregate. Nothing is sent yet:

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

The host’s Wolverine setup collects raised events from every tracked `AggregateRoot` when the handler saves, and delivers them through durable local queues backed by SQL Server:

src/AppointMe.Api/Wolverine/WolverineHostBuilderExtensions.cs

```cs
options.UseEntityFrameworkCoreTransactions();
options.PublishDomainEventsFromEntityFrameworkCore<AggregateRoot>(root => root.Events);
options.Policies.UseDurableLocalQueues();
options.Policies.AutoApplyTransactions();
```

Booking’s handler turns the event into a snapshot and applies it to its own table:

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);
    }
}
```

Booking never sees `Customer` or `CustomerId`: both live in `AppointMe.Crm`, which Booking doesn’t reference. It stores the customer’s `Guid` under its own `AttendeeId` (see [IDs & value objects](/docs/concepts/ids-and-value-objects/)).

When Booking needs every customer rather than one change, it calls a contract interface. CRM implements `ICustomerRehydrationSource` with an `internal` class. Booking’s daily `AttendeeReconciliationJob` invokes a `ReconcileAttendeesCommand` per company, and its handler pages through the interface to repair attendees that missed an event:

src/CRM/AppointMe.Crm.Contracts/Customers/ICustomerRehydrationSource.cs

```cs
public interface ICustomerRehydrationSource
{
    Task<PagedResult<CustomerSnapshot>> GetByCompanyAsync(
        Guid companyId, PaginationFilter pagination, CancellationToken cancellationToken);
}
```

How the outbox, retries and tenant hand-off work is on [Domain events & messaging](/docs/concepts/messaging/). The step-by-step recipe is [Cross-module events](/docs/guides/cross-module-events/).

## What’s allowed, and what enforces it

**Project references are the only hard boundary. The rest is convention, and there are no architecture tests.**

| From inside a module, you…                                                     | Allowed?            | What stops you                                                                               |
| ------------------------------------------------------------------------------ | ------------------- | -------------------------------------------------------------------------------------------- |
| Use `AppointMe.Shared`                                                         | Yes                 | —                                                                                            |
| Reference another module’s `.Contracts` project                                | Yes                 | You add the `ProjectReference` yourself                                                      |
| Handle another module’s event                                                  | Yes                 | —                                                                                            |
| Inject a contracts interface such as `ICustomerRehydrationSource`              | Yes, for bulk reads | —                                                                                            |
| Use another module’s aggregate, `DbContext`, typed ID or command               | No                  | The compiler. Those types live in the module project, which only `AppointMe.Api` references. |
| Add a `ProjectReference` to another module’s main project                      | No                  | Convention only. The build succeeds.                                                         |
| Query another module’s schema in SQL, such as `[crm].[Customers]` from Booking | No                  | Convention only. Every module uses the same connection string.                               |
| Raise another module’s event                                                   | No                  | Convention only. Contract records are `public`.                                              |

Booking’s project file is the whole boundary for that module:

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

```xml
<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" />
```

There’s no NetArchTest suite, Roslyn analyzer or build check for the rules above. Today no module project references another module project, and no module’s SQL names another module’s schema. Code review is what keeps it that way.

The host is the exception

`AppointMe.Api` references every module, so orchestration that needs another module’s commands lives there. `DemoSeedingSaga` in `AppointMe.Api/Demo` sends CRM’s `SeedDemoCustomersCommand` and Booking’s `SeedDemoAppointmentsCommand`. Keep the rest of the host to wiring.

## Add a module

| Step                        | Where                             | What                                                                                                                                       |
| --------------------------- | --------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------ |
| 1. Create three projects    | `src/<Module>/`                   | `AppointMe.<Module>.Contracts`, `AppointMe.<Module>` and `AppointMe.<Module>.Tests`, added to the solution                                 |
| 2. Give it a schema         | `Database/<Module>DbContext.cs`   | `modelBuilder.HasDefaultSchema("<module>")`                                                                                                |
| 3. Write the registration   | `Configuration/<Module>Module.cs` | `AddDbContext` with `MigrationsHistoryTable`, `AddDatabaseMigration`, `AddEndpoints`, `AddPermissions`, plus `[assembly: WolverineModule]` |
| 4. Register it              | `src/AppointMe.Api`               | A `ProjectReference` in `AppointMe.Api.csproj` and `.Add<Module>Module()` in `Program.cs`                                                  |
| 5. Publish what others need | `AppointMe.<Module>.Contracts`    | Events as `IDomainEvent` records with `Guid` fields, never your typed IDs                                                                  |
| 6. Migrate                  | The module                        | The first migration for the new `DbContext`. See [Database migrations](/docs/guides/database-migrations/).                                 |

The full walkthrough is [Add a module](/docs/guides/add-a-module/), and the reverse is [Remove a module](/docs/guides/remove-a-module/).

## Gotchas

| Symptom                                                                 | Cause                                                                                                    | Fix                                                                                                                            |
| ----------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------ |
| A new module’s handlers never run                                       | Its assembly has no `[assembly: WolverineModule]`, so Wolverine doesn’t scan it                          | Add the attribute, as `CrmModule.cs` does                                                                                      |
| Booking code can’t find `CustomerId`                                    | It lives in `AppointMe.Crm`, which Booking doesn’t reference                                             | That’s the boundary working. Take the `Guid` from the event and wrap it in a Booking ID.                                       |
| An attendee still shows a customer’s old name                           | The event handler failed. A `ValidationException` sends the message straight to Wolverine’s error queue. | Fix the data in CRM. The daily `booking:attendee-reconciliation` job re-applies every customer.                                |
| Pointing one module at another database doesn’t change its Dapper reads | Each module registers `IDbConnectionFactory` as a singleton, so the last registration, Booking’s, wins   | Give that module its own factory type or a keyed registration                                                                  |
| You need another module’s data during a request                         | Events are asynchronous, so there’s nothing to call                                                      | Read your own projection. For bulk reads, add an interface to the other module’s contracts, like `ICustomerRehydrationSource`. |

### Why not microservices?

One process means one container to deploy, one migration run at startup and in-process calls between modules. Cross-module events still survive a restart, because the default `SqlDurable` transport keeps them in SQL Server, so you don’t need a broker to start.

The seams a later split would need are already there: each module owns a schema, publishes contracts and keeps its own copies of other modules’ data. Cross-module traffic is already messages, and the host already supports Azure Service Bus as a transport (`Wolverine:Transport`).

The cost is that the boundaries are enforced by project references and review, not by a network. One shared database server also means one module’s heavy query can slow the others.

### Why not layered Clean Architecture?

Clean Architecture splits a solution by layer: Domain, Application, Infrastructure and Web projects. A change to one use case touches all four, and the boundary that matters most in a SaaS, such as customers versus bookings, isn’t visible in the solution at all.

Here the top-level split is by business capability, and inside a module it’s by use case. Dependency direction still holds where it pays: aggregates like `Customer` have no EF or HTTP code, and modules depend on contracts rather than implementations. There’s a fuller comparison on [vs Clean Architecture](/docs/compare/clean-architecture/).

## Next

* [Vertical slices](/docs/concepts/vertical-slices/) (Concept)
* [Add a module](/docs/guides/add-a-module/) (Guide)
* [Domain events & messaging](/docs/concepts/messaging/) (Concept)
