profileShare

rasmusjy / splitapp-backend-modular-monolith

Read-only snapshot

No repository description.

main default branch 418 files Expires Sep 13, 2026, 9:06 AM
modularmonolith.md 9,620 bytes

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:

  1. Orders module saves order → commits to its own DB
  2. Orders module publishes OrderPlacedEvent
  3. Inventory module handles event → reserves stock in its own DB
  4. 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.