ARCHITECTURE.md
13,015 bytes
| 1 | # SplitApp — Modular Monolith (Phase 3) |
|---|---|
| 2 | |
| 3 | This is the phase-3 refactor of SplitApp from a Clean/Onion monolith (phase 2) to a modular monolith. One deployable, three internally isolated modules, MediatR for cross-module communication. |
| 4 | |
| 5 | ## Deployment |
| 6 | |
| 7 | **Deployment:** runs locally via Docker Compose (see the repo root README). |
| 8 | |
| 9 | The repo's root `docker-compose.yml` + `Dockerfile` build phase 3 locally: |
| 10 | |
| 11 | | Service | Container | Builds from | Host port | Notes | |
| 12 | |---|---|---|---|---| |
| 13 | | `phase3` | `phase3` | `./Dockerfile` (root) | **`90`** | Phase 3 — modular monolith | |
| 14 | | `db` | `phase3-db` | `postgres:16` | (internal only) | PostgreSQL with schemas `users` / `trips` / `expenses` — not exposed to host | |
| 15 | |
| 16 | Bring everything up: |
| 17 | |
| 18 | ```bash |
| 19 | docker compose up --build |
| 20 | ``` |
| 21 | |
| 22 | The container exposes `http://localhost:90` (host port `90` → container port `8080`). Per-module migrations run automatically on startup. |
| 23 | |
| 24 | ## Solution layout |
| 25 | |
| 26 | ``` |
| 27 | SplitApp.sln |
| 28 | ├── src/ |
| 29 | │ ├── SplitApp.WebApp/ // composition root + admin Area + host |
| 30 | │ ├── Shared/ |
| 31 | │ │ ├── SplitApp.Shared.Kernel/ // BaseEntity, IBaseRepository, IUnitOfWork, LangStr, IdentityHelpers |
| 32 | │ │ └── SplitApp.Shared.Contracts/ // MediatR IRequest / INotification contracts |
| 33 | │ └── Modules/ |
| 34 | │ ├── Users/ |
| 35 | │ │ ├── SplitApp.Modules.Users.Domain/ // AppUser, AppRole, AppRefreshToken |
| 36 | │ │ ├── SplitApp.Modules.Users.Application/ // IIdentityService + JWT/refresh logic, MediatR handlers |
| 37 | │ │ ├── SplitApp.Modules.Users.Infrastructure/ // UsersDbContext (schema "users"), repos, AddUsersModule |
| 38 | │ │ └── SplitApp.Modules.Users.Api/ // /api/v1/identity/... controllers + DTOs |
| 39 | │ ├── Trips/ // same 4-project layout, schema "trips" |
| 40 | │ └── Expenses/ // same 4-project layout, schema "expenses" |
| 41 | └── tests/ |
| 42 | ├── SplitApp.Modules.Users.Tests/ |
| 43 | ├── SplitApp.Modules.Trips.Tests/ |
| 44 | ├── SplitApp.Modules.Expenses.Tests/ |
| 45 | └── SplitApp.WebApp.IntegrationTests/ // architecture + integration tests |
| 46 | ``` |
| 47 | |
| 48 | ## The reference rules |
| 49 | |
| 50 | Compiler-enforced via `<ProjectReference>` graph and verified by `tests/SplitApp.WebApp.IntegrationTests/Architecture/ModuleBoundaryTests.cs`: |
| 51 | |
| 52 | - A module's `Application` / `Infrastructure` / `Api` project may reference: another project inside the **same** module, plus `Shared.Kernel` and `Shared.Contracts`. **Nothing else.** |
| 53 | - Inter-module function calls go through **MediatR only** — never via a direct `<ProjectReference>` to another module's services or repositories. |
| 54 | - `Shared.*` projects may not reference any module. |
| 55 | - `WebApp` is the only project that references all three modules' `Api` and `Infrastructure` projects. |
| 56 | |
| 57 | **Caveat at the Domain level only:** to keep phase 2's view-rendering parity (mappers that read `Trip.DefaultCurrency.Code`, `TripParticipant.User.FirstName`, etc.), the entity classes still declare those navigation properties — annotated `[NotMapped]` so EF never crosses schemas. Keeping the property *types* on the entities required adding three Domain-to-Domain `<ProjectReference>`s: |
| 58 | |
| 59 | ``` |
| 60 | Modules/Trips/SplitApp.Modules.Trips.Domain |
| 61 | → Modules/Users/SplitApp.Modules.Users.Domain |
| 62 | → Modules/Expenses/SplitApp.Modules.Expenses.Domain |
| 63 | Modules/Expenses/SplitApp.Modules.Expenses.Domain |
| 64 | → Modules/Users/SplitApp.Modules.Users.Domain |
| 65 | ``` |
| 66 | |
| 67 | This bends the strict "no direct references between modules" rule from `phase3.md` at the Domain level. `Application`, `Infrastructure`, and `Api` projects remain isolated and use MediatR for actual function calls — only entity *types* are shared. The `CrossModuleNavigationTests` invariant enforces that any cross-module navigation is `[NotMapped]`; a plain mapped nav across schemas would fail the build. |
| 68 | |
| 69 | ## Inter-module communication |
| 70 | |
| 71 | All cross-module calls go through MediatR. Contracts live in `SplitApp.Shared.Contracts/<Module>/{Queries|Events}/` 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. |
| 72 | |
| 73 | Currently shipped contracts: |
| 74 | |
| 75 | | Contract | Owner module | Notes | |
| 76 | |----------|--------------|-------| |
| 77 | | `GetUserByIdQuery : IRequest<UserDto?>` | Users | Used by Trips/Expenses for display-name lookup | |
| 78 | | `GetUsersByIdsQuery : IRequest<IReadOnlyList<UserDto>>` | Users | Batch lookup | |
| 79 | | `UserDeletedEvent : INotification` | Users | Trips + Expenses subscribe to clean up rows | |
| 80 | | `GetTripByIdQuery : IRequest<TripSummaryDto?>` | Trips | Cross-module trip lookup | |
| 81 | | `GetTripParticipantsQuery : IRequest<IReadOnlyList<TripParticipantDto>>` | Trips | | |
| 82 | | `IsTripParticipantQuery : IRequest<bool>` | Trips | Used by `ExpensesController` for IDOR + payer validation | |
| 83 | | `TripDeletedEvent : INotification` | Trips | Expenses subscribes to delete dependent expenses/settlements | |
| 84 | | `GetTripExpenseTotalsQuery : IRequest<TripExpenseTotalsDto>` | Expenses | | |
| 85 | | `GetBudgetCategorySpentQuery : IRequest<IReadOnlyDictionary<Guid, decimal>>` | Expenses | Per-budget-category spent totals — used by Trips' BudgetCategoriesController | |
| 86 | | `ExpenseSettledEvent : INotification` | Expenses | Reserved for future use | |
| 87 | | `SettlementPlanCompletedEvent : INotification` | Expenses | Trips subscribes to advance "Finalizing" trips to "Settled" once every payment is confirmed | |
| 88 | |
| 89 | ## Data isolation |
| 90 | |
| 91 | Each module owns its own `DbContext`: |
| 92 | |
| 93 | - `UsersDbContext : IdentityDbContext<AppUser, AppRole, Guid>` → schema `users` |
| 94 | - `TripsDbContext : DbContext` → schema `trips` |
| 95 | - `ExpensesDbContext : DbContext` → schema `expenses` |
| 96 | |
| 97 | All three connect to the **same** physical Postgres database via the same `ConnectionStrings:DefaultConnection`. Schemas — not separate databases — provide isolation. **Cross-module SQL joins are forbidden**; cross-module data is composed at the application layer via MediatR. |
| 98 | |
| 99 | Cross-module entity references are bare `Guid` fields with **no** navigation properties and **no** EF foreign-key constraints (e.g. `Trip.CreatedById : Guid` references `users.AspNetUsers.Id` only conceptually, not via `FOREIGN KEY`). Referential integrity is maintained by: |
| 100 | |
| 101 | - Up-front MediatR validation queries (e.g. `IsTripParticipantQuery` before persisting an expense split). |
| 102 | - Domain-event cleanup on delete (`UserDeletedEvent`, `TripDeletedEvent`). |
| 103 | |
| 104 | ## Per-module migrations |
| 105 | |
| 106 | Each module ships its own EF migration history table inside its own schema. Generate new migrations against the module-specific DbContext + project, with `WebApp` as the startup project (so the connection string is read from `appsettings.json`): |
| 107 | |
| 108 | ```bash |
| 109 | # Users |
| 110 | dotnet ef migrations add <Name> -c UsersDbContext \ |
| 111 | -p src/Modules/Users/SplitApp.Modules.Users.Infrastructure/SplitApp.Modules.Users.Infrastructure.csproj \ |
| 112 | -s src/SplitApp.WebApp/SplitApp.WebApp.csproj \ |
| 113 | -o Persistence/Migrations |
| 114 | |
| 115 | # Trips |
| 116 | dotnet ef migrations add <Name> -c TripsDbContext \ |
| 117 | -p src/Modules/Trips/SplitApp.Modules.Trips.Infrastructure/SplitApp.Modules.Trips.Infrastructure.csproj \ |
| 118 | -s src/SplitApp.WebApp/SplitApp.WebApp.csproj \ |
| 119 | -o Persistence/Migrations |
| 120 | |
| 121 | # Expenses |
| 122 | dotnet ef migrations add <Name> -c ExpensesDbContext \ |
| 123 | -p src/Modules/Expenses/SplitApp.Modules.Expenses.Infrastructure/SplitApp.Modules.Expenses.Infrastructure.csproj \ |
| 124 | -s src/SplitApp.WebApp/SplitApp.WebApp.csproj \ |
| 125 | -o Persistence/Migrations |
| 126 | ``` |
| 127 | |
| 128 | Migrations are applied automatically on startup by each module's `UseXxxModule(IApplicationBuilder)` extension (called from `Program.cs`). |
| 129 | |
| 130 | ## Composition root |
| 131 | |
| 132 | `SplitApp.WebApp/Program.cs` is the only place that sees all three modules. It: |
| 133 | |
| 134 | 1. Configures cross-cutting host concerns (MVC, API versioning via `Asp.Versioning`, Swagger, authorization). |
| 135 | 2. Calls `services.AddUsersModule(...)`, `services.AddTripsModule(...)`, `services.AddExpensesModule(...)` — each module's extension wires its own `DbContext`, repositories, services, and MediatR handlers. |
| 136 | 3. Registers controllers from each module's `Api` assembly via `AddApplicationPart(...)`. |
| 137 | 4. Calls `app.UseUsersModule()`, `app.UseTripsModule()`, `app.UseExpensesModule()` — each applies its module's pending migrations. |
| 138 | |
| 139 | The composition root does **not** call `AddDbContext<...>` directly, does not register any module-internal repository/service, and does not scan module assemblies for MediatR. Each module owns its own registration. |
| 140 | |
| 141 | ## Phase 2 → Phase 3 mapping |
| 142 | |
| 143 | | Phase 2 project | Phase 3 destination | |
| 144 | |-----------------|---------------------| |
| 145 | | `Base.Domain` (`BaseEntity`, `LangStr`) | `SplitApp.Shared.Kernel` | |
| 146 | | `Base.Contracts` (`IBaseEntity`, `IBaseRepository`, `IUnitOfWork`) | `SplitApp.Shared.Kernel` | |
| 147 | | `Base.Helpers` (`IdentityHelpers`) | `SplitApp.Shared.Kernel.Auth` | |
| 148 | | `App.Domain.Identity.*` | `SplitApp.Modules.Users.Domain.Entities` | |
| 149 | | `App.Domain.{Trip,TripParticipant,...}` | `SplitApp.Modules.Trips.Domain.Entities` | |
| 150 | | `App.Domain.{Expense,SettlementPlan,...,Currency}` | `SplitApp.Modules.Expenses.Domain.Entities` | |
| 151 | | `App.DAL.EF.AppDbContext` | Split into 3 `XxxDbContext` per module | |
| 152 | | `App.BLL.Services.Identity.*` | `SplitApp.Modules.Users.Application.Services` | |
| 153 | | `WebApp.ApiControllers.Identity.*` | `SplitApp.Modules.Users.Api.Controllers` | |
| 154 | | `WebApp.ApiControllers.{TripsController,...}` | `SplitApp.Modules.Trips.Api.Controllers` | |
| 155 | | `WebApp.ApiControllers.{ExpensesController,...}` | `SplitApp.Modules.Expenses.Api.Controllers` | |
| 156 | |
| 157 | ## Composition root: MVC + Razor + Admin |
| 158 | |
| 159 | The WebApp hosts the full phase 2 UI surface (101 Razor views, 21 MVC + Admin controllers, identity Razor pages). It is the **only** place that sees all three modules. Phase 2 is preserved by lifting the BLL layer into [src/SplitApp.WebApp/Application/](../src/SplitApp.WebApp/Application/) under three buckets: |
| 160 | |
| 161 | - **DTO** — phase 2 BLL DTOs unchanged (POCOs; views model-bind to these) |
| 162 | - **Mappers** — entity ↔ DTO factories, unchanged structurally; cross-module nav fields populate from `[NotMapped]` properties |
| 163 | - **Services + Services/Admin + Services/Identity** — phase 2 BLL services unchanged; they consume `IAppUnitOfWork` |
| 164 | |
| 165 | [`Application/Persistence/AppUnitOfWork.cs`](../src/SplitApp.WebApp/Application/Persistence/AppUnitOfWork.cs) is the composition-root facade: it injects all three module DbContexts (`UsersDbContext`, `TripsDbContext`, `ExpensesDbContext`) and exposes them through phase 2's `IAppUnitOfWork` interface. Each repository routes to its module's DbContext — schemas stay isolated, but the host can compose across them. |
| 166 | |
| 167 | [`Application/Persistence/CrossModuleNavigationLoader.cs`](../src/SplitApp.WebApp/Application/Persistence/CrossModuleNavigationLoader.cs) hydrates the `[NotMapped]` cross-module nav properties (`Trip.DefaultCurrency`, `TripParticipant.User`, `Expense.PaidByUser`, etc.) after entities are loaded. EF never crosses schemas; the loader pulls cross-module data via a separate query against the appropriate module's DbContext. |
| 168 | |
| 169 | ### MVC client controllers |
| 170 | |
| 171 | `Controllers/{Home, Trips, Expenses, Budget, Members, Settlement, PollsClient, WishlistClient}Controller` — the user-facing site. Cookie-authenticated. |
| 172 | |
| 173 | ### Admin area |
| 174 | |
| 175 | `Areas/Admin/Controllers/{Dashboard, Users, Trips, Expenses, BudgetCategories, Currencies, Invitations, Polls, SettlementPayments, SettlementPlans, SplitPresets, TripParticipants, Wishlist}Controller` — admin CRUD per entity, gated on the `admin` role. |
| 176 | |
| 177 | ### Identity Razor page |
| 178 | |
| 179 | `Areas/Identity/Pages/Account/Register` — cookie-based registration. JWT flows are handled by `Modules.Users.Api.AccountController`. |
| 180 | |
| 181 | Migrations are skipped when `ASPNETCORE_ENVIRONMENT=Testing` so `WebApplicationFactory<Program>` can boot without a real Postgres instance — see [`tests/SplitApp.WebApp.IntegrationTests/HostBootSmokeTests.cs`](../tests/SplitApp.WebApp.IntegrationTests/HostBootSmokeTests.cs). |
| 182 | |
| 183 | ## Architecture tests |
| 184 | |
| 185 | [`tests/SplitApp.WebApp.IntegrationTests/Architecture/`](../tests/SplitApp.WebApp.IntegrationTests/Architecture/) runs as part of `dotnet test` and asserts: |
| 186 | |
| 187 | 1. **`ModuleBoundaryTests`** — no module project has a `<ProjectReference>` to another module's Application/Infrastructure/Api project; no `Shared.*` project references a module. |
| 188 | 2. **`DbContextSchemaIsolationTests`** — each `DbContext` only exposes `DbSet<T>` for entities that live in its own Domain project (plus a tiny framework allowlist). |
| 189 | 3. **`CrossModuleNavigationTests`** — Domain entities may declare navigation properties to another module's entity type **only** when annotated `[NotMapped]`. EF never loads these — the WebApp facade hydrates them in-memory after fetching from the appropriate module's DbContext. |
| 190 | |
| 191 | A failing test means a developer just violated the modular-monolith invariant. |
| 192 | |