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