profileShare

rasmusjy / splitapp-backend-microservices

Read-only snapshot

No repository description.

main default branch 501 files Expires Sep 13, 2026, 9:06 AM
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