Modular Monolith One deployable, but internally split into self-contained modules. Each module owns its domain, data, and services. Modules communicate through well-defined interfaces — not by reaching into each other's guts.
Think of it as: Clean Architecture applied per feature/domain, all living in one process.
Why
Approach Problem Classic monolith Everything references everything. One change -> cascade everywhere Microservices Distributed systems hell. Network, serialization, eventual consistency. Overkill for most teams Modular monolith Clean boundaries like microservices, deployment simplicity of a monolith. Split later if you actually need to Structure
MyApp.sln ├── MyApp.Web // Composition root, routing, DI wiring │ ├── Modules/ │ ├── MyApp.Modules.Persons/ │ │ ├── Domain/ // Entities, interfaces │ │ ├── Application/ // Services, DTOs │ │ ├── Infrastructure/ // EF configs, repos │ │ └── Api/ // Controllers or endpoints │ │ │ ├── MyApp.Modules.Orders/ │ │ ├── Domain/ │ │ ├── Application/ │ │ ├── Infrastructure/ │ │ └── Api/ │ │ │ └── MyApp.Modules.Notifications/ │ ├── Domain/ │ ├── Application/ │ ├── Infrastructure/ │ └── Api/ │ ├── MyApp.Shared.Contracts/ // Cross-module interfaces, shared DTOs └── MyApp.Shared.Infrastructure/ // Common utilities, base classes
Each module is a mini Clean Architecture. Each module has its own DbContext scoped to its tables — modules don't share database contexts.
The golden rule
Modules never reference each other's internals. Module A doesn't touch Module B's entities, repositories, or DbContext.
Communication goes through:
Contracts (interfaces in shared project): Domain events (in-process, loose coupling): // Shared public record PersonDeletedEvent(int PersonId);
// Persons module publishes (replaced with messaging in microservices) await _mediator.Publish(new PersonDeletedEvent(id));
// Orders module handles public class PersonDeletedHandler : INotificationHandler { public async Task Handle(PersonDeletedEvent e, CancellationToken ct) { // cancel pending orders, clean up references } }
Separate DbContexts per module
This is what enforces the boundary at the data level:
// Persons module — only sees Person tables public class PersonDbContext : DbContext { public DbSet Persons => Set(); public DbSet Addresses => Set();
protected override void OnModelCreating(ModelBuilder b)
{
b.HasDefaultSchema("persons"); // schema isolation
}
}
// Orders module — only sees Order tables public class OrderDbContext : DbContext { public DbSet Orders => Set(); public DbSet OrderLines => Set();
protected override void OnModelCreating(ModelBuilder b)
{
b.HasDefaultSchema("orders");
}
}
Same database server, different schemas. No cross-module joins. If Orders needs person data, it goes through IPersonModuleApi, not a SQL join.
Coupling vs Cohesion — the two metrics that matter
Coupling = how much modules depend on each other's internals.
High Coupling Low Coupling How Direct references, shared DB context, calling internal methods Contracts, events, shared DTOs only Change impact Touch one module -> break three others Touch one module -> others don't notice Testing Need to spin up half the app Test module in isolation Cohesion = how related the stuff inside a module is.
High Cohesion Low Cohesion How Everything in the module serves one domain concept Grab-bag of unrelated utilities Example PersonService + PersonRepository + PersonValidator UtilityService with email + tax + image resize Symptom Module name describes exactly what's inside Module name is vague ("Helpers", "Common", "Utils") The goal: low coupling between modules, high cohesion within modules.
"How do I know if my module boundaries are right?" — the answer is: look at how often a change in one module forces a change in another. If it's frequent, your boundary is in the wrong place. The communication patterns (contracts, events) should cross boundaries rarely, not on every request.
The migration path — why this matters practically
Classic Monolith -> Modular Monolith -> Microservices (if you ever actually need to)
Each module already has its own schema, its own contracts, its own DbContext. Extracting a module into a separate service later means replacing in-process IPersonModuleApi calls with HTTP/gRPC calls, and domain events with a message broker. The code structure barely changes — only the transport layer does.
Most teams never need that last step. The modular monolith gives you 90% of the organizational benefits of microservices with none of the distributed systems pain.
MyApp — Single Deployment
Shared Contracts
Notifications Module
Orders Module
Persons Module
Web / Composition Root
uses contract
implements
subscribes to
publishes
IPersonModuleApi
Api
Application
Domain
Infrastructure
persons schema
Api
Application
Domain
Infrastructure
orders schema
Api
Application
Domain
Infrastructure
notifications schema
Routing
Domain Events
IOrderModuleApi
DI Wiring
OK Low Coupling (modular monolith)
publishes event
subscribes
calls contract
implements
subscribes
Person Module
Event Bus
Order Module
IPersonModuleApi
Notification Module
BAD High Coupling (classic monolith)
direct DB access
references
direct DB access
references
calls internal method
references everything
Person Service
Order Tables
Order Entities
Order Service
Person Tables
Person Entities
Notification Service
BAD Low Cohesion (grab bag service)
UtilityService
SendEmail
CalculateTax
ResizeImage
ValidatePerson
GenerateReport
OK High Cohesion (inside a module)
PersonService
PersonRepository
PersonValidator
PersonMapper
Person Entity
Eventual Consistency In a monolith with one database, you get immediate consistency — save data, read it back, it's there. One transaction, one commit, done.
The moment you split into modules, services, or separate databases — that guarantee breaks. You get eventual consistency instead: changes propagate, but not instantly. There's a window where different parts of the system see different data.
The classic example
User places an order. Three things need to happen:
Save the order (Orders module) Reserve inventory (Inventory module) Send confirmation email (Notifications module) Immediate consistency approach — distributed transaction:
BEGIN TRANSACTION Insert order → Orders DB Decrement stock → Inventory DB Queue email → Notifications DB COMMIT
This is a distributed transaction (2PC). All three succeed or all three roll back. Sounds clean. In practice: slow, fragile, doesn't scale, most modern databases and message brokers don't even support it properly.
Eventual consistency approach — events:
- Orders module saves order → commits to its own DB
- Orders module publishes OrderPlacedEvent
- Inventory module handles event → reserves stock in its own DB
- Notifications module handles event → sends email
Each step is a local transaction — fast, reliable. But between steps 1 and 3, the order exists without reserved inventory. That's the consistency window.
What can go wrong
Timeline: T0: Order saved → Orders DB has the order T1: Event published → on the bus T2: Inventory handler → starts processing ------- consistency window ------- T3: Stock reserved → Inventory DB updated
Between T0 and T3, the system is "inconsistent":
- Orders says: "order exists"
- Inventory says: "stock not reserved yet"
If someone queries inventory at T1, they see stale data. If the inventory handler crashes at T2, the order exists but stock is never reserved.
In the modular monolith context
In-process with MediatR, eventual consistency happens between SaveChangesAsync calls in different modules. The window is small (milliseconds), but it exists.
// Persons module await _personRepo.DeleteAsync(id); await _personUow.SaveChangesAsync(); // committed to persons schema await _mediator.Publish(new PersonDeletedEvent(id));
// Orders module handler — runs after, separate DbContext public async Task Handle(PersonDeletedEvent e, CancellationToken ct) { await _orderRepo.CancelOrdersForPerson(e.PersonId); await _orderUow.SaveChangesAsync(); // committed to orders schema // if this fails, person is deleted but orders still exist — inconsistent }
Why not just use one database transaction
Scenario One transaction works? Notes Monolith, one DbContext Yes Just use SaveChangesAsync() Modular monolith, same DB server Maybe TransactionScope works but couples modules Modular monolith, separate DBs No Need events + outbox Microservices No Need events + outbox + compensations Third-party APIs (email, payment) No Can't roll back an email The pragmatic answer: use immediate consistency as long as you can. One DbContext, one SaveChangesAsync. Only go eventual when the architecture forces it — separate modules, separate databases, external services. Don't adopt eventual consistency for the aesthetics. Look into saga pattern for compensating transactions.