architecture.md
20,634 bytes
| 1 | # Architecture — SplitApp (Microservices) |
|---|---|
| 2 | |
| 3 | This document is the architectural deep-dive for the microservices implementation. It builds on the modular monolith by **extracting the Users module into a separate microservice**. The two processes communicate via **RabbitMQ** (for inter-service RPC + events) and **HTTP REST** (for login / register / admin CRUD). |
| 4 | |
| 5 | --- |
| 6 | |
| 7 | ## 1. The picture |
| 8 | |
| 9 | ``` |
| 10 | ┌──────────────────────┐ HTTP REST (login/register/admin) ┌──────────────────────┐ |
| 11 | │ SplitApp.WebApp │ ◄──────────────────────────────────► │ SplitApp.UsersService│ |
| 12 | │ (monolith, port 97) │ │ (microservice, 98) │ |
| 13 | │ │ RabbitMQ (RPC + events, JSON) │ │ |
| 14 | │ - Trips module │ ◄──────────────────────────────────► │ - Users.Domain │ |
| 15 | │ - Expenses module │ │ - Users.Application │ |
| 16 | │ - MVC client UX │ │ - Users.Infrastructure│ |
| 17 | │ - Full Admin UX │ │ - Users.Api (REST) │ |
| 18 | │ - JWT (validate) │ │ - Identity + UoW │ |
| 19 | │ - No Users.* refs │ │ - JWT issuer │ |
| 20 | └──────────┬───────────┘ └───────────┬───────────┘ |
| 21 | │ │ |
| 22 | ▼ ▼ |
| 23 | ┌───────────────┐ ┌───────────────┐ |
| 24 | │ db-monolith │ ┌──────────────┐ │ db-users │ |
| 25 | │ (Postgres 16) │ │ rabbitmq │ │ (Postgres 16) │ |
| 26 | │ trips schema │ │ exchanges: │ │ Identity │ |
| 27 | │ expenses sch. │ │ splitapp. │ │ (AspNetUsers, │ |
| 28 | │ │ │ events │ │ AspNetRoles, │ |
| 29 | │ │ │ splitapp. │ │ RefreshToken)│ |
| 30 | │ │ │ requests │ │ │ |
| 31 | └───────────────┘ └──────────────┘ └───────────────┘ |
| 32 | ``` |
| 33 | |
| 34 | Two ASP.NET Core 10 processes. Each owns its own Postgres database and its own DbContext. JWT is validated locally in both services using a **shared symmetric HS256 key** in config — there's no roundtrip to validate. |
| 35 | |
| 36 | --- |
| 37 | |
| 38 | ## 2. Solution layout |
| 39 | |
| 40 | ``` |
| 41 | SplitApp.Modular/ |
| 42 | ├── SplitApp.sln |
| 43 | ├── Directory.Build.props |
| 44 | ├── src/ |
| 45 | │ ├── SplitApp.WebApp/ ← monolith host |
| 46 | │ │ ├── Program.cs ← JWT bearer + cookie reader, messaging, HTTP client wiring |
| 47 | │ │ ├── Application/ |
| 48 | │ │ │ ├── Services/ (+ Admin/) ← BLL — IUsersServiceClient for user lookups |
| 49 | │ │ │ ├── DTO/ ← BllDtos (UserDto-shaped) |
| 50 | │ │ │ ├── Mappers/ ← Domain ↔ BllDto factories |
| 51 | │ │ │ ├── Persistence/AppUnitOfWork.cs ← Trips + Expenses DbContexts, IUserLookup for hydration |
| 52 | │ │ │ ├── Persistence/CrossModuleNavigationLoader.cs ← uses IUserLookup (RabbitMQ RPC) |
| 53 | │ │ │ ├── UsersService/ ← typed HttpClient + JwtForwardingHandler + DTOs |
| 54 | │ │ │ └── Messaging/UserDeletedEventHandler.cs ← RabbitMQ event subscriber |
| 55 | │ │ ├── Controllers/AccountController.cs ← MVC login/register/logout |
| 56 | │ │ ├── Controllers/{Trips, Expenses, Settlement, ...}.cs ← MVC, read user via claims |
| 57 | │ │ ├── Areas/Admin/ ← full admin UX |
| 58 | │ │ └── Resources/ ← i18n .resx (EN + ET) |
| 59 | │ ├── Services/Users/SplitApp.UsersService/ ← NEW microservice host |
| 60 | │ │ ├── Program.cs ← AddUsersModule + messaging consumers + Swagger |
| 61 | │ │ ├── Messaging/ ← request handlers, UserDtoMapper |
| 62 | │ │ ├── Controllers/HealthController.cs ← /health (gated on seed completion) |
| 63 | │ │ ├── Hosting/ ← Swagger options + SeededHealthState |
| 64 | │ │ └── appsettings.json ← own JWT + connection string config |
| 65 | │ ├── Shared/ |
| 66 | │ │ ├── SplitApp.Shared.Kernel/ ← BaseEntity, IBaseRepo, IUoW, LangStr, IdentityHelpers |
| 67 | │ │ ├── SplitApp.Shared.Contracts/ ← in-process MediatR contracts (Trips ↔ Expenses) + UserDto |
| 68 | │ │ └── SplitApp.Shared.Messaging/ ← RabbitMQ wrapper + integration messages + IUserLookup |
| 69 | │ │ ├── IMessageBus.cs, IIntegrationEvent.cs, IIntegrationRequest.cs |
| 70 | │ │ ├── Internal/RabbitMqBus.cs ← publish + RPC client (TCS map + reply queue) |
| 71 | │ │ ├── Internal/RabbitMqConsumerHostedService.cs ← server-side consumer BackgroundService |
| 72 | │ │ └── Integration/Users/ ← GetUserByIdRequest, GetUsersByIdsRequest, |
| 73 | │ │ UserDeletedEvent, IUserLookup, UserLookup |
| 74 | │ └── Modules/ |
| 75 | │ ├── Users/ ← 4 projects — consumed by UsersService host only |
| 76 | │ ├── Trips/ ← in-process module in WebApp |
| 77 | │ └── Expenses/ ← in-process module in WebApp |
| 78 | └── tests/ |
| 79 | ├── SplitApp.Modules.{Users,Trips,Expenses}.Tests/ |
| 80 | ├── SplitApp.Shared.Messaging.Tests/ ← NEW: integration message contract tests |
| 81 | └── SplitApp.WebApp.IntegrationTests/ |
| 82 | ``` |
| 83 | |
| 84 | The `src/Modules/Users/*` folder is **not deleted** — its 4 projects (Domain, Application, Infrastructure, Api) are the building blocks consumed by the new `SplitApp.UsersService` host. From the monolith's perspective, the Users module is invisible — `SplitApp.WebApp.csproj` has zero references to any `Users.*` project. |
| 85 | |
| 86 | --- |
| 87 | |
| 88 | ## 3. Communication contract |
| 89 | |
| 90 | | Concern | Channel | Direction | |
| 91 | |---|---|---| |
| 92 | | Login / Register / Refresh / Logout | HTTP `/api/v1/identity/account/*` | WebApp → UsersService | |
| 93 | | Admin user list / edit / delete / roles | HTTP `/api/v1/identity/admin/*` | WebApp → UsersService | |
| 94 | | Inter-module user lookup (id → DisplayName/Email) | RabbitMQ RPC (`GetUserByIdRequest`, `GetUsersByIdsRequest`) | WebApp → UsersService | |
| 95 | | User deletion fan-out | RabbitMQ event (`UserDeletedEvent`) | UsersService → WebApp | |
| 96 | | JWT validation | Local (shared HS256 key in config) | both services | |
| 97 | |
| 98 | ### Why two channels? |
| 99 | |
| 100 | - **HTTP** for *command-style* flows where the caller is a human-driven request and needs a synchronous answer: login, admin CRUD. JWT in `Authorization: Bearer` (forwarded from inbound cookie/header via `JwtForwardingHandler`). |
| 101 | - **RabbitMQ** for *cross-module data* lookups (joining a Trip with its participants' display names), where coupling the two services via HTTP would mean every page render does N HTTP calls. RPC over MQ with reply queue + correlation IDs makes it loose, asynchronous, and resilient to brief service outages (graceful degradation — see §7). |
| 102 | - **Events** for fan-out cascades — when a user is deleted in the Users service, the monolith subscribes via `splitapp.events` topic exchange and cleans up `TripParticipant` + `ExpenseSplit` rows. |
| 103 | |
| 104 | --- |
| 105 | |
| 106 | ## 4. The Shared.Messaging library |
| 107 | |
| 108 | Custom-built wrapper around `RabbitMQ.Client` v7 (raw client — no MassTransit / NServiceBus / etc.). Provides: |
| 109 | |
| 110 | ### Public surface |
| 111 | - **`IMessageBus`** — `PublishEventAsync<T>` + `RequestAsync<TReq, TResp>(timeout)` |
| 112 | - **`IUserLookup`** — high-level facade so consumers don't take a direct dep on `IMessageBus` (only knows about `UserDto`) |
| 113 | - **`IIntegrationEvent`** + **`IIntegrationRequest<TResponse>`** — marker interfaces |
| 114 | - **`IEventHandler<T>`** + **`IRequestHandler<TReq, TResp>`** — handler interfaces, DI-resolved per delivery in a scope |
| 115 | - **`AddMessaging(IConfiguration, serviceName)`** + **`AddIntegrationEventHandler<T, THandler>()`** + **`AddIntegrationRequestHandler<TReq, TResp, THandler>()`** |
| 116 | |
| 117 | ### Internals |
| 118 | - **`RabbitMqConnectionProvider`** — singleton, lazily opens one shared `IConnection`. Polly-based initial-connect retry (exponential 1s/2s/4s/…/30s, up to 8 attempts) so startup ordering is forgiving when a service boots before RabbitMQ. |
| 119 | - **`RabbitMqBus`** — publish + RPC client. One long-lived consumer channel + one **exclusive auto-delete reply queue** per bus instance. `ConcurrentDictionary<string, TaskCompletionSource<ReadOnlyMemory<byte>>>` keyed by correlation ID. On request: create TCS, register, basic-publish with `ReplyTo + CorrelationId`, wait with timeout. On reply receipt: look up TCS, `TrySetResult`. On connection shutdown: drain all pending TCSs with `MessageBusUnavailableException` so callers fail fast instead of hanging. |
| 120 | - **`RabbitMqConsumerHostedService`** — server-side `BackgroundService`. On start, declares one durable queue per registered handler (`q.<serviceName>.<MessageType>`) and starts an `AsyncEventingBasicConsumer`. Each delivery: deserialise body, resolve handler from a DI scope, await it; for RPC handlers, publish the response back to `ReplyTo` with the original `CorrelationId`. Ack on success, nack (no requeue) on handler exception. |
| 121 | - **`RabbitMqTopology`** — two exchanges declared idempotently: `splitapp.events` (topic, durable) and `splitapp.requests` (direct, durable). Routing keys = CLR type names. |
| 122 | |
| 123 | ### Wire format |
| 124 | JSON, system properties on the AMQP envelope: |
| 125 | - `Type` = message type name |
| 126 | - `CorrelationId` = GUID (RPC only) |
| 127 | - `ReplyTo` = the requester's exclusive reply queue (RPC only) |
| 128 | - `ContentType` = `application/json` |
| 129 | - Body = `JsonSerializer.SerializeToUtf8Bytes(payload)` of the request/event record |
| 130 | |
| 131 | Tested with 9 round-trip contract tests in `SplitApp.Shared.Messaging.Tests`. |
| 132 | |
| 133 | --- |
| 134 | |
| 135 | ## 5. JWT — shared signing key, dual cookie + header |
| 136 | |
| 137 | Both services use the same `JWT:Key/Issuer/Audience` configuration (passed via env vars in compose). The Users service is the **only issuer** — it signs the JWT during `POST /api/v1/identity/account/login` after credential check. The webapp's JWT-bearer middleware validates the token *locally* — no roundtrip. |
| 138 | |
| 139 | ### MVC session |
| 140 | The webapp `AccountController` posts credentials to the Users service, receives `{ jwt, refreshToken, firstName, lastName }`, then stores the JWT in an HttpOnly + SameSite=Lax `jwt` cookie and the refresh token in a `refresh` cookie. |
| 141 | |
| 142 | To make MVC views work without a manual `Authorization` header, the JWT bearer middleware has an `OnMessageReceived` event that reads the `jwt` cookie when the `Authorization` header is missing: |
| 143 | |
| 144 | ```csharp |
| 145 | options.Events = new JwtBearerEvents |
| 146 | { |
| 147 | OnMessageReceived = ctx => |
| 148 | { |
| 149 | if (string.IsNullOrEmpty(ctx.Token) |
| 150 | && ctx.Request.Cookies.TryGetValue("jwt", out var cookieJwt)) |
| 151 | { |
| 152 | ctx.Token = cookieJwt; |
| 153 | } |
| 154 | return Task.CompletedTask; |
| 155 | }, |
| 156 | }; |
| 157 | ``` |
| 158 | |
| 159 | ### REST clients |
| 160 | External SPA clients can still POST to `users-service:98/api/v1/identity/account/login` directly, get the JWT, and call any endpoint on either service with `Authorization: Bearer <jwt>`. |
| 161 | |
| 162 | ### Service-to-service |
| 163 | The webapp's `JwtForwardingHandler` (a `DelegatingHandler` registered on the typed `IUsersServiceClient` HttpClient) propagates the *inbound* JWT (whichever channel it arrived on — cookie or header) to the *outbound* HTTP call to the Users service. That's how admin CRUD endpoints get their JWT even though they're called from server-side MVC code. |
| 164 | |
| 165 | --- |
| 166 | |
| 167 | ## 6. WebApp decoupling — what changed from Phase 3 |
| 168 | |
| 169 | | Concern | Phase 3 (in-process) | Phase 4 (cross-process) | |
| 170 | |---|---|---| |
| 171 | | `UsersDbContext` | Registered in WebApp DI | NOT in WebApp — only in UsersService | |
| 172 | | `[NotMapped] AppUser? CreatedBy` on Trip/Expense | `AppUser` (Users.Domain entity) | `UserDto` (Shared.Contracts type) | |
| 173 | | `UserManager<AppUser>` in 13 MVC controllers/views | Direct injection | All replaced with `User.FindFirstValue(ClaimTypes.NameIdentifier)` claim reads | |
| 174 | | Cross-module user lookup | `_mediator.Send(new GetUsersByIdsQuery(...))` | `_users.GetByIdsAsync(...)` via `IUserLookup` (RabbitMQ RPC) | |
| 175 | | Admin user CRUD | `Areas/Admin/UsersController` injects `UserManager` directly | `Areas/Admin/UsersController` calls `IUsersServiceClient` (typed HTTP) | |
| 176 | | Login/Register UX | `Areas/Identity/Pages/Account/*` (scaffolded Razor Pages) | `Controllers/AccountController` (new MVC) + `Views/Account/*` | |
| 177 | | `UserDeletedEvent` cascade | MediatR `INotificationHandler<T>` in Trips/Expenses infra | `IEventHandler<UserDeletedEvent>` in `WebApp/Application/Messaging/` | |
| 178 | | Project references | WebApp.csproj → `Users.Api` + `Users.Infrastructure` | Zero `Users.*` references from WebApp | |
| 179 | | ASP.NET Identity package refs | `Identity.EntityFrameworkCore` + `Identity.UI` | Removed | |
| 180 | | DataProtection key store | `PersistKeysToDbContext<UsersDbContext>` | `PersistKeysToFileSystem("/app/keys")` (mounted volume) | |
| 181 | |
| 182 | About 22 files in the WebApp were touched in this purge. The `Modules/Users/*` projects themselves are unchanged — they just have a new host. |
| 183 | |
| 184 | --- |
| 185 | |
| 186 | ## 7. Resilience — what happens when things go wrong |
| 187 | |
| 188 | | Failure | Behaviour | |
| 189 | |---|---| |
| 190 | | RabbitMQ down at startup | `RabbitMqConnectionProvider` Polly-retries the initial connect (1s/2s/4s/…/30s, 8 attempts). Other services boot regardless — handlers come online when the broker is reachable. | |
| 191 | | RabbitMQ goes down mid-flight (RPC in progress) | `OnConnectionShutdownAsync` drains all pending TCSs with `MessageBusUnavailableException`. `IUserLookup` catches and returns an empty result — UI shows blank user names instead of crashing the page. | |
| 192 | | RPC timeout (default 10s) | `MessageBusTimeoutException` → `IUserLookup` catches → empty result. Same graceful degradation. | |
| 193 | | Users service down | Login attempts return a `Users service unreachable` error (`UsersServiceClient` wraps the HTTP failure into `UsersServiceResult<T>.Fail`). Admin pages throw — they propagate the 5xx. RabbitMQ traffic also degrades (no consumer). | |
| 194 | | First boot, Users service still seeding | Compose `depends_on: condition: service_healthy` keeps the webapp from starting until `/health` returns 200, which the Users service flips only after migrate + seed completes. | |
| 195 | | Users service signed JWT, monolith can't validate | Both load `JWT:Key/Issuer/Audience` from environment; compose hands them the **same** values. If they diverge, all `[Authorize]` requests on the monolith return 401. | |
| 196 | |
| 197 | --- |
| 198 | |
| 199 | ## 8. Tests (53 total) |
| 200 | |
| 201 | ```bash |
| 202 | cd SplitApp.Modular |
| 203 | dotnet test |
| 204 | ``` |
| 205 | |
| 206 | | Project | Tests | Covers | |
| 207 | |---|---:|---| |
| 208 | | `SplitApp.Modules.Users.Tests` | 8 | `IdentityHelpers` — JWT generation + validation round-trips, reject malformed/expired/wrong-issuer/wrong-key/wrong-audience tokens | |
| 209 | | `SplitApp.Modules.Trips.Tests` | 12 | `LangStr` — translation lookup, fallback chain, empty/null cases | |
| 210 | | `SplitApp.Modules.Expenses.Tests` | 10 | `CurrencyConverter` — exchange rates, rounding, signs, round-trip precision | |
| 211 | | `SplitApp.Shared.Messaging.Tests` | 9 | Wire-format round-trips for every integration message (`GetUserByIdRequest`, `GetUsersByIdsRequest`, `UserDeletedEvent`, `UserDto`), marker-interface invariants, `RabbitMqTopology.HandlerQueueName` stability | |
| 212 | | `SplitApp.WebApp.IntegrationTests` | 14 | Architecture invariants (module boundaries, schema isolation, `[NotMapped]` rule) + `WebApplicationFactory<Program>` HTTP smoke (`/`, `/Home/Index`, Swagger UI + v1 doc, admin auth gating, REST API requires JWT, `?culture=et` localization) | |
| 213 | |
| 214 | Architecture tests in `tests/SplitApp.WebApp.IntegrationTests/Architecture/`: |
| 215 | 1. **`ModuleBoundaryTests`** — no module's `Application`/`Infrastructure`/`Api` project references another module's project; `Shared.*` references no module. |
| 216 | 2. **`DbContextSchemaIsolationTests`** — each `DbContext` exposes `DbSet<T>` only for entities in its own module's `Domain`. |
| 217 | 3. **`CrossModuleNavigationTests`** — cross-module navigation properties must be `[NotMapped]`. |
| 218 | 4. **`HostBootSmokeTests`** — `WebApplicationFactory<Program>` boots the host (no Users module in the WebApp anymore — proves the decoupling works at runtime). |
| 219 | |
| 220 | A failing architecture test means someone violated an invariant — the build stops. |
| 221 | |
| 222 | --- |
| 223 | |
| 224 | ## 9. Deployment |
| 225 | |
| 226 | **Compose stack:** |
| 227 | |
| 228 | ```bash |
| 229 | docker compose -p splitapp-phase4 up --build -d |
| 230 | ``` |
| 231 | |
| 232 | Five containers: |
| 233 | |
| 234 | | Service | Container | Host port | Notes | |
| 235 | |---|---|---|---| |
| 236 | | `webapp` | `phase4-webapp` | http://localhost:97 | The MVC monolith | |
| 237 | | `users-service` | `phase4-users-service` | http://localhost:98 | The Users microservice (own Swagger at `/swagger`) | |
| 238 | | `rabbitmq` | `phase4-rabbitmq` | http://localhost:15672 (UI), `:5672` (AMQP) | Management UI: `guest`/`guest` | |
| 239 | | `db-monolith` | `phase4-db-monolith` | (internal) | PostgreSQL 16 — Trips + Expenses tables | |
| 240 | | `db-users` | `phase4-db-users` | (internal) | PostgreSQL 16 — Identity tables | |
| 241 | |
| 242 | **Healthcheck-gated startup:** `webapp depends_on: users-service condition: service_healthy`. The Users service's `/health` only returns 200 after `UseUsersModule()` (migrate + seed) finishes — the webapp waits for that. |
| 243 | |
| 244 | **Volumes:** `phase4-monolith-pgdata`, `phase4-users-pgdata`, `phase4-webapp-keys` (the last preserves DataProtection keys across restarts so cookies + antiforgery survive). |
| 245 | |
| 246 | **CI/CD:** [.gitlab-ci.yml](.gitlab-ci.yml) — single `deploy` stage runs `docker compose -p splitapp-phase4 up --build --remove-orphans --detach` on push to `main` (same shape as Phase 3; tests are run locally before push). |
| 247 | |
| 248 | --- |
| 249 | |
| 250 | ## 10. Why microservices (over modular monolith)? |
| 251 | |
| 252 | Extracting a service is a deliberate trade-off, not a default. The modular monolith is already well-factored: clean module boundaries, schema-per-module isolation, MediatR for cross-module calls. Microservices add real costs: |
| 253 | |
| 254 | | Aspect | Modular monolith | Microservices | |
| 255 | |---|---|---| |
| 256 | | Deployment | 1 container | 5 containers + healthcheck choreography | |
| 257 | | Inter-module call latency | Method call (nanoseconds) | RPC over RabbitMQ (~milliseconds) | |
| 258 | | Transactional consistency | One Postgres transaction across modules | Eventual consistency via events + cascade handlers | |
| 259 | | Independent deploy/scale | All-or-nothing | Each service can be scaled / deployed independently | |
| 260 | | Failure isolation | Crash takes everything | Users service crash leaves Trips + Expenses functional (with degraded user display) | |
| 261 | | Local dev | `dotnet run` | `docker compose up` + cross-process logs | |
| 262 | |
| 263 | This demonstrates the **extraction pattern**: the modular monolith was structured precisely so that one module could be lifted out with minimal rewrites — only the transport layer changes. The `Users` module's 4 projects (`Domain`/`Application`/`Infrastructure`/`Api`) didn't change at all; only the host did. The plumbing that swapped from in-process MediatR to RabbitMQ + HTTP is concentrated in two new files: `SplitApp.Shared.Messaging` (the bus) and `SplitApp.WebApp.Application.UsersService.UsersServiceClient` (the HTTP client). That separation is the payoff of a well-factored modular monolith. |
| 264 | |