profileShare

rasmusjy / splitapp-backend-modular-monolith

Read-only snapshot

No repository description.

main default branch 418 files Expires Sep 13, 2026, 9:06 AM
architecture.md 15,543 bytes
1 # Architecture — SplitApp Phase 3 (Modular Monolith)
2
3 This document is the architectural deep-dive for the Phase 3 refactor. The whole product lives under [`SplitApp.Modular/`](SplitApp.Modular/): one deployable, three internally isolated modules (**Users**, **Trips**, **Expenses**), MediatR for cross-module communication, schema-per-module Postgres isolation.
4
5 For the original course context, see also [`modularmonolith.md`](modularmonolith.md) and [`phase3.md`](phase3.md). The module-level README at [`SplitApp.Modular/README.md`](SplitApp.Modular/README.md) and the dedicated [`SplitApp.Modular/docs/ARCHITECTURE.md`](SplitApp.Modular/docs/ARCHITECTURE.md) are the canonical references; this file is the high-level overview.
6
7 ---
8
9 ## 1. The picture
10
11 ```
12 ┌────────────────────────────────────────────────┐
13 │ SplitApp.WebApp (host) │
14 │ Program.cs · Controllers · Areas/Admin │
15 │ Application/{Services, DTO, Mappers, │
16 │ Persistence} │
17 └────────────────────────────────────────────────┘
18 │ │ │
19 ▼ ▼ ▼
20 ┌──────────┐ ┌──────────┐ ┌──────────┐
21 │ Users │ │ Trips │ │ Expenses │
22 │ Domain │ │ Domain │ │ Domain │
23 │ App │ │ App │ │ App │
24 │ Infra │ │ Infra │ │ Infra │
25 │ Api │ │ Api │ │ Api │
26 │ schema: │ │ schema: │ │ schema: │
27 │ users │ │ trips │ │ expenses │
28 └──────────┘ └──────────┘ └──────────┘
29 ▲ ▲ ▲
30 └─MediatR───┴─MediatR───┘
31 ┌──────────────────────┐ ┌──────────────────────┐
32 │ Shared.Contracts │ │ Shared.Kernel │
33 │ IRequest/INotification│ │ BaseEntity, LangStr │
34 └──────────────────────┘ └──────────────────────┘
35 ```
36
37 Each module = mini-Clean-Architecture (`Domain` ← `Application` ← `Infrastructure`, `Api` for REST). Each module owns its own `DbContext` scoped to its own Postgres schema. Cross-module function calls go through MediatR only — no direct `<ProjectReference>` between modules' `Application` / `Infrastructure` / `Api` layers.
38
39 ---
40
41 ## 2. Module layout
42
43 ```
44 SplitApp.Modular/
45 ├── SplitApp.sln
46 ├── Directory.Build.props
47 ├── src/
48 │ ├── SplitApp.WebApp/ ← composition root, host, admin Area
49 │ │ ├── Program.cs ← AddXxxModule(...) wiring
50 │ │ ├── Application/ ← lifted phase-2 BLL
51 │ │ │ ├── Services/ (+ Admin/, Identity/)
52 │ │ │ ├── DTO/ ← BllDtos
53 │ │ │ ├── Mappers/ ← Domain ↔ BllDto factory mappers
54 │ │ │ ├── Persistence/AppUnitOfWork.cs ← aggregates 3 module DbContexts
55 │ │ │ └── Persistence/CrossModuleNavigationLoader.cs
56 │ │ ├── Areas/Admin/, Areas/Identity/, Controllers/, Views/, Resources/
57 │ ├── Shared/
58 │ │ ├── SplitApp.Shared.Kernel/ ← BaseEntity, IBaseRepo, IUoW, LangStr, IdentityHelpers
59 │ │ └── SplitApp.Shared.Contracts/ ← MediatR IRequest / INotification
60 │ └── Modules/
61 │ ├── Users/
62 │ │ ├── SplitApp.Modules.Users.Domain/ ← AppUser, AppRole, AppRefreshToken
63 │ │ ├── SplitApp.Modules.Users.Application/ ← IIdentityService + JWT/refresh, MediatR handlers
64 │ │ ├── SplitApp.Modules.Users.Infrastructure/ ← UsersDbContext (schema "users"), repos, AddUsersModule
65 │ │ └── SplitApp.Modules.Users.Api/ ← /api/v1/identity/...
66 │ ├── Trips/ ← same 4-project layout, schema "trips"
67 │ └── Expenses/ ← same 4-project layout, schema "expenses"
68 └── tests/
69 ├── SplitApp.Modules.{Users,Trips,Expenses}.Tests/ ← per-module unit tests
70 └── SplitApp.WebApp.IntegrationTests/ ← architecture invariants + smoke
71 ```
72
73 ---
74
75 ## 3. Reference rules (compiler-enforced + arch-test verified)
76
77 | Source | Allowed targets | Notes |
78 |--------|----------------|-------|
79 | `Modules/X/Application` | same-module `Domain` + `Shared.Kernel` + `Shared.Contracts` | |
80 | `Modules/X/Infrastructure` | same-module `Domain` + `Application` + `Shared.Kernel` | |
81 | `Modules/X/Api` | same-module `Application` + `Shared.Kernel` + `Shared.Contracts` | |
82 | `Shared.*` | none of the modules | |
83 | `WebApp` | all 3 modules' `Api` + `Infrastructure` + `Shared.*` | composition root |
84
85 **Domain-level caveat:** to keep view-rendering parity from phase 2 (mappers reading `Trip.DefaultCurrency.Code`, `TripParticipant.User.FirstName`, etc.), entity classes still declare cross-module navigation properties — annotated `[NotMapped]` so EF never crosses Postgres schemas. To make those property *types* compile, three Domain-to-Domain `<ProjectReference>`s exist:
86
87 ```
88 Modules/Trips/SplitApp.Modules.Trips.Domain
89 → Modules/Users/SplitApp.Modules.Users.Domain (for AppUser refs)
90 → Modules/Expenses/SplitApp.Modules.Expenses.Domain (for Currency refs)
91 Modules/Expenses/SplitApp.Modules.Expenses.Domain
92 → Modules/Users/SplitApp.Modules.Users.Domain (for AppUser refs)
93 ```
94
95 This bends the strict "no direct references between modules" rule from `phase3.md` at the **Domain** level. **`Application`, `Infrastructure`, `Api` projects remain isolated** and use MediatR for actual function calls — only entity *types* are shared. Schema isolation, MediatR-only inter-module function calls, and per-module DbContext ownership are all preserved at runtime.
96
97 `CrossModuleNavigationTests` enforces the `[NotMapped]` rule: a *mapped* navigation across modules (where EF would actually try to traverse) fails the build.
98
99 ---
100
101 ## 4. Inter-module communication (MediatR)
102
103 Cross-module calls go through MediatR. Contracts live in `SplitApp.Shared.Contracts/<Module>/{Queries|Events|Commands}/` and are records implementing `IRequest<T>` (sync queries/commands) or `INotification` (fan-out events). Handlers live in the **owning** module's `Application` or `Infrastructure` layer.
104
105 | Contract | Owner | Notes |
106 |----------|-------|-------|
107 | `GetUserByIdQuery : IRequest<UserDto?>` | Users | Used by Trips/Expenses for display-name lookup |
108 | `GetUsersByIdsQuery : IRequest<IReadOnlyList<UserDto>>` | Users | Batch lookup |
109 | `UserDeletedEvent : INotification` | Users | Trips + Expenses subscribe to clean up rows |
110 | `GetTripByIdQuery : IRequest<TripSummaryDto?>` | Trips | Cross-module trip lookup |
111 | `GetTripParticipantsQuery : IRequest<IReadOnlyList<TripParticipantDto>>` | Trips | |
112 | `IsTripParticipantQuery : IRequest<bool>` | Trips | IDOR guard in `ExpensesController` |
113 | `TripDeletedEvent : INotification` | Trips | Expenses subscribes to delete dependent expenses/settlements |
114 | `GetTripExpenseTotalsQuery : IRequest<TripExpenseTotalsDto>` | Expenses | Per-currency totals for a trip |
115 | `GetBudgetCategorySpentQuery : IRequest<IReadOnlyDictionary<Guid, decimal>>` | Expenses | Per-budget-category spent totals |
116 | `ExpenseSettledEvent : INotification` | Expenses | Reserved for future use |
117 | `SettlementPlanCompletedEvent : INotification` | Expenses | Trips subscribes to advance "Finalizing" trips to "Settled" once every payment is confirmed |
118
119 ---
120
121 ## 5. Schema isolation
122
123 Each module owns its own `DbContext`:
124
125 - `UsersDbContext : IdentityDbContext<AppUser, AppRole, Guid>` → schema `users`
126 - `TripsDbContext : DbContext` → schema `trips`
127 - `ExpensesDbContext : DbContext` → schema `expenses`
128
129 All three connect to the same physical Postgres database via the same `ConnectionStrings:DefaultConnection`. **Cross-module SQL joins are forbidden** — composition happens at the application layer through MediatR or the WebApp facade's `CrossModuleNavigationLoader`.
130
131 Cross-module entity references are bare `Guid` fields with **no** EF foreign-key constraints (e.g. `Trip.CreatedById : Guid` references `users.AspNetUsers.Id` only conceptually). Referential integrity is maintained by:
132
133 - Up-front MediatR validation queries (e.g. `IsTripParticipantQuery` before persisting an expense split)
134 - Domain-event cleanup on delete (`UserDeletedEvent`, `TripDeletedEvent`)
135
136 ---
137
138 ## 6. Composition root and lifted phase 2 BLL
139
140 `SplitApp.WebApp` is the only project that sees all three modules. It hosts the full phase 2 UI surface (101 Razor views, 21 MVC + Admin controllers, 10 REST API controllers, Identity Razor Register page) and lifts the entire phase 2 BLL layer into [`SplitApp.WebApp/Application/`](SplitApp.Modular/src/SplitApp.WebApp/Application/) under three buckets:
141
142 | Bucket | Contents |
143 |---|---|
144 | `Application/DTO` | Phase-2 BLL DTOs (`TripBllDto`, `ExpenseBllDto`, …) — POCOs that views model-bind to |
145 | `Application/Mappers` | Entity ↔ BLL DTO factory mappers |
146 | `Application/Services` (+ `Services/Admin`, `Services/Identity`) | Phase-2 BLL services lifted unchanged; consume `IAppUnitOfWork` |
147 | `Application/Persistence/AppUnitOfWork.cs` | Composition-root facade aggregating `UsersDbContext`, `TripsDbContext`, `ExpensesDbContext` behind phase-2's `IAppUnitOfWork` interface — each repository routes to the appropriate module's DbContext |
148 | `Application/Persistence/CrossModuleNavigationLoader.cs` | Hydrates `[NotMapped]` cross-module nav properties (`Trip.DefaultCurrency`, `TripParticipant.User`, `Expense.PaidByUser`, …) after entity load by querying the owning module's DbContext separately. EF never crosses schemas. |
149
150 The lifted BLL services keep working unchanged: `_uow.Trips.GetByIdAsync(...)` behaves like phase 2. In reality `AppUnitOfWork.Trips` routes the query to `TripsDbContext`, and `CrossModuleHydration` fills cross-module navs (`Trip.CreatedBy`, `Trip.DefaultCurrency`) afterwards via separate single-shot batched queries against the foreign module's DbContext.
151
152 ---
153
154 ## 7. Phase 3 → phase 2 mapping (for context)
155
156 | Phase 2 project | Phase 3 destination |
157 |---|---|
158 | `Base.Domain` (`BaseEntity`, `LangStr`) | `SplitApp.Shared.Kernel` |
159 | `Base.Contracts` (`IBaseEntity`, `IBaseRepository`, `IUnitOfWork`) | `SplitApp.Shared.Kernel` |
160 | `Base.Helpers` (`IdentityHelpers`) | `SplitApp.Shared.Kernel.Auth` |
161 | `App.Domain.Identity.*` | `SplitApp.Modules.Users.Domain.Entities` |
162 | `App.Domain.{Trip, TripParticipant, …}` | `SplitApp.Modules.Trips.Domain.Entities` |
163 | `App.Domain.{Expense, SettlementPlan, …, Currency}` | `SplitApp.Modules.Expenses.Domain.Entities` |
164 | `App.DAL.EF.AppDbContext` | Split into 3 `XxxDbContext` per module |
165 | `App.BLL.Services.Identity.*` | `SplitApp.Modules.Users.Application.Services` |
166 | `App.BLL.Services.*` (Trip, Expense, Settlement, …) | Lifted into `SplitApp.WebApp/Application/Services` (composition-root facade over the 3 module UoWs) |
167 | `App.BLL.DTO.*`, `App.BLL.Mappers.*` | Lifted into `SplitApp.WebApp/Application/{DTO, Mappers}` |
168 | `WebApp.ApiControllers.Identity.*` | `SplitApp.Modules.Users.Api.Controllers` |
169 | `WebApp.ApiControllers.{TripsController, …}` | `SplitApp.Modules.Trips.Api.Controllers` |
170 | `WebApp.ApiControllers.{ExpensesController, CurrenciesController, SettlementsController, SplitPresetsController}` | `SplitApp.Modules.Expenses.Api.Controllers` |
171 | `WebApp/Controllers/*` (MVC client) + `WebApp/Areas/Admin/*` + `WebApp/Areas/Identity/*` + `WebApp/Views/*` | `SplitApp.Modular/src/SplitApp.WebApp/{Controllers, Areas/Admin, Areas/Identity, Views}` (preserved structurally; namespace re-rooted to `SplitApp.WebApp.*`) |
172
173 ---
174
175 ## 8. Architecture tests (run on `dotnet test`)
176
177 `tests/SplitApp.WebApp.IntegrationTests/Architecture/`:
178
179 1. **`ModuleBoundaryTests`** — no module's `Application`/`Infrastructure`/`Api` project has a `<ProjectReference>` to another module's project; no `Shared.*` project references a module.
180 2. **`DbContextSchemaIsolationTests`** — each `DbContext` only exposes `DbSet<T>` for entities that live in its own `Domain` project (plus a tiny framework allowlist).
181 3. **`CrossModuleNavigationTests`** — cross-module navigation properties are allowed only when annotated `[NotMapped]`. A plain mapped nav across modules (which would let EF cross schemas) fails the build.
182 4. **`HostBootSmokeTests`** — `WebApplicationFactory<Program>` boots the full host in `Testing` env (skipping per-module migrations) and verifies `/`, `/Home/Index`, and a 401 on unauthenticated API hits.
183
184 A failing architecture test means a developer just violated the modular-monolith invariant.
185
186 **Total test count: 25** — Expenses (5) + Trips (7) + Users (4) + IntegrationTests (9). All passing on the current build.
187
188 ---
189
190 ## 9. Deployment
191
192 **Production deployment:** https://travel.rasmusj.com/
193
194 The repo's root `Dockerfile` + `docker-compose.yml` build and run phase 3 locally:
195
196 ```bash
197 docker compose up --build
198 ```
199
200 | Service | Container | Port | Notes |
201 |---|---|---|---|
202 | `phase3` | `phase3` | http://localhost:90 | Web app (host port `90` → container port `8080`) |
203 | `db` | `phase3-db` | (internal only) | PostgreSQL 16, schemas `users` / `trips` / `expenses` — not exposed to host |
204
205 Per-module migrations run automatically on host startup. Sample data is seeded if `DataInitialization:SeedData=true`.
206
207 For per-module migration commands (`dotnet ef migrations add ...`) and the full URL map, see [`SplitApp.Modular/docs/ARCHITECTURE.md`](SplitApp.Modular/docs/ARCHITECTURE.md) and [`SplitApp.Modular/README.md`](SplitApp.Modular/README.md).
208
209 ---
210
211 ## 10. Why a modular monolith?
212
213 | Approach | Problem |
214 |---|---|
215 | Classic monolith | Everything references everything — one change cascades |
216 | Microservices | Distributed-systems pain — network, serialization, eventual consistency, deployment complexity |
217 | **Modular monolith** | **Microservice-style boundaries + monolith deployment simplicity** |
218
219 Future extraction path, if needed: *Classic monolith → Modular monolith → Microservices*. 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 MediatR calls with HTTP/gRPC and domain events with a message broker — the code structure barely changes, only the transport layer.
220
221 See [`modularmonolith.md`](modularmonolith.md) for the course material on the pattern.
222