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.
- Modules
- Identity, Organizations, CRM, Booking
- Runs as
- One process,
AppointMe.Api - Data
- One database, one schema per module
- Cross-module
.Contractsevents and interfaces
A module is three projects
Section titled “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.
BookingDbContext, migrations| 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, 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
Section titled “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 |
.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). 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).
Registering a module
Section titled “Registering a module”The host calls one Add*Module() extension per module in Program.cs, and each extension registers everything its module needs.
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. |
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
Section titled “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.
CustomerRegisteredEventCustomerRegisteredEventHandler upserts an AttendeeICustomerRehydrationSourceThe event is a record of plain values in AppointMe.Crm.Contracts:
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:
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:
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:
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).
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:
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. The step-by-step recipe is Cross-module events.
What’s allowed, and what enforces it
Section titled “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:
<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.
Add a module
Section titled “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. |
The full walkthrough is Add a module, and the reverse is Remove a module.
Gotchas
Section titled “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?2 min · optional
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?2 min · optional
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.