modularmonolith.md
9,620 bytes
| 1 | Modular Monolith |
|---|---|
| 2 | 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. |
| 3 | |
| 4 | Think of it as: Clean Architecture applied per feature/domain, all living in one process. |
| 5 | |
| 6 | Why |
| 7 | |
| 8 | Approach Problem |
| 9 | Classic monolith Everything references everything. One change -> cascade everywhere |
| 10 | Microservices Distributed systems hell. Network, serialization, eventual consistency. Overkill for most teams |
| 11 | Modular monolith Clean boundaries like microservices, deployment simplicity of a monolith. Split later if you actually need to |
| 12 | Structure |
| 13 | |
| 14 | MyApp.sln |
| 15 | ├── MyApp.Web // Composition root, routing, DI wiring |
| 16 | │ |
| 17 | ├── Modules/ |
| 18 | │ ├── MyApp.Modules.Persons/ |
| 19 | │ │ ├── Domain/ // Entities, interfaces |
| 20 | │ │ ├── Application/ // Services, DTOs |
| 21 | │ │ ├── Infrastructure/ // EF configs, repos |
| 22 | │ │ └── Api/ // Controllers or endpoints |
| 23 | │ │ |
| 24 | │ ├── MyApp.Modules.Orders/ |
| 25 | │ │ ├── Domain/ |
| 26 | │ │ ├── Application/ |
| 27 | │ │ ├── Infrastructure/ |
| 28 | │ │ └── Api/ |
| 29 | │ │ |
| 30 | │ └── MyApp.Modules.Notifications/ |
| 31 | │ ├── Domain/ |
| 32 | │ ├── Application/ |
| 33 | │ ├── Infrastructure/ |
| 34 | │ └── Api/ |
| 35 | │ |
| 36 | ├── MyApp.Shared.Contracts/ // Cross-module interfaces, shared DTOs |
| 37 | └── MyApp.Shared.Infrastructure/ // Common utilities, base classes |
| 38 | |
| 39 | Each module is a mini Clean Architecture. Each module has its own DbContext scoped to its tables — modules don't share database contexts. |
| 40 | |
| 41 | The golden rule |
| 42 | |
| 43 | Modules never reference each other's internals. Module A doesn't touch Module B's entities, repositories, or DbContext. |
| 44 | |
| 45 | Communication goes through: |
| 46 | |
| 47 | Contracts (interfaces in shared project): |
| 48 | Domain events (in-process, loose coupling): |
| 49 | // Shared |
| 50 | public record PersonDeletedEvent(int PersonId); |
| 51 | |
| 52 | // Persons module publishes (replaced with messaging in microservices) |
| 53 | await _mediator.Publish(new PersonDeletedEvent(id)); |
| 54 | |
| 55 | // Orders module handles |
| 56 | public class PersonDeletedHandler : INotificationHandler<PersonDeletedEvent> |
| 57 | { |
| 58 | public async Task Handle(PersonDeletedEvent e, CancellationToken ct) |
| 59 | { |
| 60 | // cancel pending orders, clean up references |
| 61 | } |
| 62 | } |
| 63 | |
| 64 | Separate DbContexts per module |
| 65 | |
| 66 | This is what enforces the boundary at the data level: |
| 67 | |
| 68 | // Persons module — only sees Person tables |
| 69 | public class PersonDbContext : DbContext |
| 70 | { |
| 71 | public DbSet<Person> Persons => Set<Person>(); |
| 72 | public DbSet<Address> Addresses => Set<Address>(); |
| 73 | |
| 74 | protected override void OnModelCreating(ModelBuilder b) |
| 75 | { |
| 76 | b.HasDefaultSchema("persons"); // schema isolation |
| 77 | } |
| 78 | } |
| 79 | |
| 80 | // Orders module — only sees Order tables |
| 81 | public class OrderDbContext : DbContext |
| 82 | { |
| 83 | public DbSet<Order> Orders => Set<Order>(); |
| 84 | public DbSet<OrderLine> OrderLines => Set<OrderLine>(); |
| 85 | |
| 86 | protected override void OnModelCreating(ModelBuilder b) |
| 87 | { |
| 88 | b.HasDefaultSchema("orders"); |
| 89 | } |
| 90 | } |
| 91 | |
| 92 | Same database server, different schemas. No cross-module joins. If Orders needs person data, it goes through IPersonModuleApi, not a SQL join. |
| 93 | |
| 94 | Coupling vs Cohesion — the two metrics that matter |
| 95 | |
| 96 | Coupling = how much modules depend on each other's internals. |
| 97 | |
| 98 | High Coupling Low Coupling |
| 99 | How Direct references, shared DB context, calling internal methods Contracts, events, shared DTOs only |
| 100 | Change impact Touch one module -> break three others Touch one module -> others don't notice |
| 101 | Testing Need to spin up half the app Test module in isolation |
| 102 | Cohesion = how related the stuff inside a module is. |
| 103 | |
| 104 | High Cohesion Low Cohesion |
| 105 | How Everything in the module serves one domain concept Grab-bag of unrelated utilities |
| 106 | Example PersonService + PersonRepository + PersonValidator UtilityService with email + tax + image resize |
| 107 | Symptom Module name describes exactly what's inside Module name is vague ("Helpers", "Common", "Utils") |
| 108 | The goal: low coupling between modules, high cohesion within modules. |
| 109 | |
| 110 | "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. |
| 111 | |
| 112 | The migration path — why this matters practically |
| 113 | |
| 114 | Classic Monolith -> Modular Monolith -> Microservices (if you ever actually need to) |
| 115 | |
| 116 | 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. |
| 117 | |
| 118 | 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. |
| 119 | |
| 120 | MyApp — Single Deployment |
| 121 | |
| 122 | Shared Contracts |
| 123 | |
| 124 | Notifications Module |
| 125 | |
| 126 | Orders Module |
| 127 | |
| 128 | Persons Module |
| 129 | |
| 130 | Web / Composition Root |
| 131 | |
| 132 | uses contract |
| 133 | |
| 134 | implements |
| 135 | |
| 136 | subscribes to |
| 137 | |
| 138 | publishes |
| 139 | |
| 140 | IPersonModuleApi |
| 141 | |
| 142 | Api |
| 143 | |
| 144 | Application |
| 145 | |
| 146 | Domain |
| 147 | |
| 148 | Infrastructure |
| 149 | |
| 150 | persons schema |
| 151 | |
| 152 | Api |
| 153 | |
| 154 | Application |
| 155 | |
| 156 | Domain |
| 157 | |
| 158 | Infrastructure |
| 159 | |
| 160 | orders schema |
| 161 | |
| 162 | Api |
| 163 | |
| 164 | Application |
| 165 | |
| 166 | Domain |
| 167 | |
| 168 | Infrastructure |
| 169 | |
| 170 | notifications schema |
| 171 | |
| 172 | Routing |
| 173 | |
| 174 | Domain Events |
| 175 | |
| 176 | IOrderModuleApi |
| 177 | |
| 178 | DI Wiring |
| 179 | |
| 180 | OK Low Coupling (modular monolith) |
| 181 | |
| 182 | publishes event |
| 183 | |
| 184 | subscribes |
| 185 | |
| 186 | calls contract |
| 187 | |
| 188 | implements |
| 189 | |
| 190 | subscribes |
| 191 | |
| 192 | Person Module |
| 193 | |
| 194 | Event Bus |
| 195 | |
| 196 | Order Module |
| 197 | |
| 198 | IPersonModuleApi |
| 199 | |
| 200 | Notification Module |
| 201 | |
| 202 | BAD High Coupling (classic monolith) |
| 203 | |
| 204 | direct DB access |
| 205 | |
| 206 | references |
| 207 | |
| 208 | direct DB access |
| 209 | |
| 210 | references |
| 211 | |
| 212 | calls internal method |
| 213 | |
| 214 | references everything |
| 215 | |
| 216 | Person Service |
| 217 | |
| 218 | Order Tables |
| 219 | |
| 220 | Order Entities |
| 221 | |
| 222 | Order Service |
| 223 | |
| 224 | Person Tables |
| 225 | |
| 226 | Person Entities |
| 227 | |
| 228 | Notification Service |
| 229 | |
| 230 | BAD Low Cohesion (grab bag service) |
| 231 | |
| 232 | UtilityService |
| 233 | |
| 234 | SendEmail |
| 235 | |
| 236 | CalculateTax |
| 237 | |
| 238 | ResizeImage |
| 239 | |
| 240 | ValidatePerson |
| 241 | |
| 242 | GenerateReport |
| 243 | |
| 244 | OK High Cohesion (inside a module) |
| 245 | |
| 246 | PersonService |
| 247 | |
| 248 | PersonRepository |
| 249 | |
| 250 | PersonValidator |
| 251 | |
| 252 | PersonMapper |
| 253 | |
| 254 | Person Entity |
| 255 | |
| 256 | Eventual Consistency |
| 257 | In a monolith with one database, you get immediate consistency — save data, read it back, it's there. One transaction, one commit, done. |
| 258 | |
| 259 | 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. |
| 260 | |
| 261 | The classic example |
| 262 | |
| 263 | User places an order. Three things need to happen: |
| 264 | |
| 265 | Save the order (Orders module) |
| 266 | Reserve inventory (Inventory module) |
| 267 | Send confirmation email (Notifications module) |
| 268 | Immediate consistency approach — distributed transaction: |
| 269 | |
| 270 | BEGIN TRANSACTION |
| 271 | Insert order → Orders DB |
| 272 | Decrement stock → Inventory DB |
| 273 | Queue email → Notifications DB |
| 274 | COMMIT |
| 275 | |
| 276 | 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. |
| 277 | |
| 278 | Eventual consistency approach — events: |
| 279 | |
| 280 | 1. Orders module saves order → commits to its own DB |
| 281 | 2. Orders module publishes OrderPlacedEvent |
| 282 | 3. Inventory module handles event → reserves stock in its own DB |
| 283 | 4. Notifications module handles event → sends email |
| 284 | |
| 285 | 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. |
| 286 | |
| 287 | What can go wrong |
| 288 | |
| 289 | Timeline: |
| 290 | T0: Order saved → Orders DB has the order |
| 291 | T1: Event published → on the bus |
| 292 | T2: Inventory handler → starts processing |
| 293 | ------- consistency window ------- |
| 294 | T3: Stock reserved → Inventory DB updated |
| 295 | |
| 296 | Between T0 and T3, the system is "inconsistent": |
| 297 | - Orders says: "order exists" |
| 298 | - Inventory says: "stock not reserved yet" |
| 299 | |
| 300 | 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. |
| 301 | |
| 302 | In the modular monolith context |
| 303 | |
| 304 | In-process with MediatR, eventual consistency happens between SaveChangesAsync calls in different modules. The window is small (milliseconds), but it exists. |
| 305 | |
| 306 | // Persons module |
| 307 | await _personRepo.DeleteAsync(id); |
| 308 | await _personUow.SaveChangesAsync(); // committed to persons schema |
| 309 | await _mediator.Publish(new PersonDeletedEvent(id)); |
| 310 | |
| 311 | // Orders module handler — runs after, separate DbContext |
| 312 | public async Task Handle(PersonDeletedEvent e, CancellationToken ct) |
| 313 | { |
| 314 | await _orderRepo.CancelOrdersForPerson(e.PersonId); |
| 315 | await _orderUow.SaveChangesAsync(); // committed to orders schema |
| 316 | // if this fails, person is deleted but orders still exist — inconsistent |
| 317 | } |
| 318 | |
| 319 | Why not just use one database transaction |
| 320 | |
| 321 | Scenario One transaction works? Notes |
| 322 | Monolith, one DbContext Yes Just use SaveChangesAsync() |
| 323 | Modular monolith, same DB server Maybe TransactionScope works but couples modules |
| 324 | Modular monolith, separate DBs No Need events + outbox |
| 325 | Microservices No Need events + outbox + compensations |
| 326 | Third-party APIs (email, payment) No Can't roll back an email |
| 327 | 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. |
| 328 | Look into saga pattern for compensating transactions. |