Skip to content
StarLive demo →
ConceptChecked against main · 31 Oct 20269 min read

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
.Contracts events and interfaces

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

AppointMe.Apithe host: registers every module
AppointMe.Bookingslices, BookingDbContext, migrations
AppointMe.Crm.Contractsevents, snapshots, one interface
AppointMe.Sharedthe shared kernel
Figure 1. Booking references CRM's contracts project, never CRM itself.
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

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.csView source ↗
.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).

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

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

CRM: Customer.Registerraises CustomerRegisteredEvent
SaveChangesAsyncthe event goes into the outbox in the same transaction
Booking handlerCustomerRegisteredEventHandler upserts an Attendee
CustomerUpdatedEventupdates the attendee
CustomerDeletedEventsoft-deletes the attendee
Daily at 03:00a job re-syncs from ICustomerRehydrationSource
Figure 2. One event, defined in CRM's contracts and handled in Booking.

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

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;

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

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

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.csView source ↗
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.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);
}
}

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:

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

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.csprojView source ↗
<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.

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.

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.