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
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.