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