Architecture — SplitApp (Microservices)
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).
1. The picture
┌──────────────────────┐ HTTP REST (login/register/admin) ┌──────────────────────┐
│ SplitApp.WebApp │ ◄──────────────────────────────────► │ SplitApp.UsersService│
│ (monolith, port 97) │ │ (microservice, 98) │
│ │ RabbitMQ (RPC + events, JSON) │ │
│ - Trips module │ ◄──────────────────────────────────► │ - Users.Domain │
│ - Expenses module │ │ - Users.Application │
│ - MVC client UX │ │ - Users.Infrastructure│
│ - Full Admin UX │ │ - Users.Api (REST) │
│ - JWT (validate) │ │ - Identity + UoW │
│ - No Users.* refs │ │ - JWT issuer │
└──────────┬───────────┘ └───────────┬───────────┘
│ │
▼ ▼
┌───────────────┐ ┌───────────────┐
│ db-monolith │ ┌──────────────┐ │ db-users │
│ (Postgres 16) │ │ rabbitmq │ │ (Postgres 16) │
│ trips schema │ │ exchanges: │ │ Identity │
│ expenses sch. │ │ splitapp. │ │ (AspNetUsers, │
│ │ │ events │ │ AspNetRoles, │
│ │ │ splitapp. │ │ RefreshToken)│
│ │ │ requests │ │ │
└───────────────┘ └──────────────┘ └───────────────┘
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.
2. Solution layout
SplitApp.Modular/
├── SplitApp.sln
├── Directory.Build.props
├── src/
│ ├── SplitApp.WebApp/ ← monolith host
│ │ ├── Program.cs ← JWT bearer + cookie reader, messaging, HTTP client wiring
│ │ ├── Application/
│ │ │ ├── Services/ (+ Admin/) ← BLL — IUsersServiceClient for user lookups
│ │ │ ├── DTO/ ← BllDtos (UserDto-shaped)
│ │ │ ├── Mappers/ ← Domain ↔ BllDto factories
│ │ │ ├── Persistence/AppUnitOfWork.cs ← Trips + Expenses DbContexts, IUserLookup for hydration
│ │ │ ├── Persistence/CrossModuleNavigationLoader.cs ← uses IUserLookup (RabbitMQ RPC)
│ │ │ ├── UsersService/ ← typed HttpClient + JwtForwardingHandler + DTOs
│ │ │ └── Messaging/UserDeletedEventHandler.cs ← RabbitMQ event subscriber
│ │ ├── Controllers/AccountController.cs ← MVC login/register/logout
│ │ ├── Controllers/{Trips, Expenses, Settlement, ...}.cs ← MVC, read user via claims
│ │ ├── Areas/Admin/ ← full admin UX
│ │ └── Resources/ ← i18n .resx (EN + ET)
│ ├── Services/Users/SplitApp.UsersService/ ← NEW microservice host
│ │ ├── Program.cs ← AddUsersModule + messaging consumers + Swagger
│ │ ├── Messaging/ ← request handlers, UserDtoMapper
│ │ ├── Controllers/HealthController.cs ← /health (gated on seed completion)
│ │ ├── Hosting/ ← Swagger options + SeededHealthState
│ │ └── appsettings.json ← own JWT + connection string config
│ ├── Shared/
│ │ ├── SplitApp.Shared.Kernel/ ← BaseEntity, IBaseRepo, IUoW, LangStr, IdentityHelpers
│ │ ├── SplitApp.Shared.Contracts/ ← in-process MediatR contracts (Trips ↔ Expenses) + UserDto
│ │ └── SplitApp.Shared.Messaging/ ← RabbitMQ wrapper + integration messages + IUserLookup
│ │ ├── IMessageBus.cs, IIntegrationEvent.cs, IIntegrationRequest.cs
│ │ ├── Internal/RabbitMqBus.cs ← publish + RPC client (TCS map + reply queue)
│ │ ├── Internal/RabbitMqConsumerHostedService.cs ← server-side consumer BackgroundService
│ │ └── Integration/Users/ ← GetUserByIdRequest, GetUsersByIdsRequest,
│ │ UserDeletedEvent, IUserLookup, UserLookup
│ └── Modules/
│ ├── Users/ ← 4 projects — consumed by UsersService host only
│ ├── Trips/ ← in-process module in WebApp
│ └── Expenses/ ← in-process module in WebApp
└── tests/
├── SplitApp.Modules.{Users,Trips,Expenses}.Tests/
├── SplitApp.Shared.Messaging.Tests/ ← NEW: integration message contract tests
└── SplitApp.WebApp.IntegrationTests/
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.
3. Communication contract
| Concern | Channel | Direction |
|---|---|---|
| Login / Register / Refresh / Logout | HTTP /api/v1/identity/account/* |
WebApp → UsersService |
| Admin user list / edit / delete / roles | HTTP /api/v1/identity/admin/* |
WebApp → UsersService |
| Inter-module user lookup (id → DisplayName/Email) | RabbitMQ RPC (GetUserByIdRequest, GetUsersByIdsRequest) |
WebApp → UsersService |
| User deletion fan-out | RabbitMQ event (UserDeletedEvent) |
UsersService → WebApp |
| JWT validation | Local (shared HS256 key in config) | both services |
Why two channels?
- 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 viaJwtForwardingHandler). - 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).
- Events for fan-out cascades — when a user is deleted in the Users service, the monolith subscribes via
splitapp.eventstopic exchange and cleans upTripParticipant+ExpenseSplitrows.
4. The Shared.Messaging library
Custom-built wrapper around RabbitMQ.Client v7 (raw client — no MassTransit / NServiceBus / etc.). Provides:
Public surface
IMessageBus—PublishEventAsync<T>+RequestAsync<TReq, TResp>(timeout)IUserLookup— high-level facade so consumers don't take a direct dep onIMessageBus(only knows aboutUserDto)IIntegrationEvent+IIntegrationRequest<TResponse>— marker interfacesIEventHandler<T>+IRequestHandler<TReq, TResp>— handler interfaces, DI-resolved per delivery in a scopeAddMessaging(IConfiguration, serviceName)+AddIntegrationEventHandler<T, THandler>()+AddIntegrationRequestHandler<TReq, TResp, THandler>()
Internals
RabbitMqConnectionProvider— singleton, lazily opens one sharedIConnection. 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.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 withReplyTo + CorrelationId, wait with timeout. On reply receipt: look up TCS,TrySetResult. On connection shutdown: drain all pending TCSs withMessageBusUnavailableExceptionso callers fail fast instead of hanging.RabbitMqConsumerHostedService— server-sideBackgroundService. On start, declares one durable queue per registered handler (q.<serviceName>.<MessageType>) and starts anAsyncEventingBasicConsumer. Each delivery: deserialise body, resolve handler from a DI scope, await it; for RPC handlers, publish the response back toReplyTowith the originalCorrelationId. Ack on success, nack (no requeue) on handler exception.RabbitMqTopology— two exchanges declared idempotently:splitapp.events(topic, durable) andsplitapp.requests(direct, durable). Routing keys = CLR type names.
Wire format
JSON, system properties on the AMQP envelope:
Type= message type nameCorrelationId= GUID (RPC only)ReplyTo= the requester's exclusive reply queue (RPC only)ContentType=application/json- Body =
JsonSerializer.SerializeToUtf8Bytes(payload)of the request/event record
Tested with 9 round-trip contract tests in SplitApp.Shared.Messaging.Tests.
5. JWT — shared signing key, dual cookie + header
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.
MVC session
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.
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:
options.Events = new JwtBearerEvents
{
OnMessageReceived = ctx =>
{
if (string.IsNullOrEmpty(ctx.Token)
&& ctx.Request.Cookies.TryGetValue("jwt", out var cookieJwt))
{
ctx.Token = cookieJwt;
}
return Task.CompletedTask;
},
};
REST clients
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>.
Service-to-service
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.
6. WebApp decoupling — what changed from Phase 3
| Concern | Phase 3 (in-process) | Phase 4 (cross-process) |
|---|---|---|
UsersDbContext |
Registered in WebApp DI | NOT in WebApp — only in UsersService |
[NotMapped] AppUser? CreatedBy on Trip/Expense |
AppUser (Users.Domain entity) |
UserDto (Shared.Contracts type) |
UserManager<AppUser> in 13 MVC controllers/views |
Direct injection | All replaced with User.FindFirstValue(ClaimTypes.NameIdentifier) claim reads |
| Cross-module user lookup | _mediator.Send(new GetUsersByIdsQuery(...)) |
_users.GetByIdsAsync(...) via IUserLookup (RabbitMQ RPC) |
| Admin user CRUD | Areas/Admin/UsersController injects UserManager directly |
Areas/Admin/UsersController calls IUsersServiceClient (typed HTTP) |
| Login/Register UX | Areas/Identity/Pages/Account/* (scaffolded Razor Pages) |
Controllers/AccountController (new MVC) + Views/Account/* |
UserDeletedEvent cascade |
MediatR INotificationHandler<T> in Trips/Expenses infra |
IEventHandler<UserDeletedEvent> in WebApp/Application/Messaging/ |
| Project references | WebApp.csproj → Users.Api + Users.Infrastructure |
Zero Users.* references from WebApp |
| ASP.NET Identity package refs | Identity.EntityFrameworkCore + Identity.UI |
Removed |
| DataProtection key store | PersistKeysToDbContext<UsersDbContext> |
PersistKeysToFileSystem("/app/keys") (mounted volume) |
About 22 files in the WebApp were touched in this purge. The Modules/Users/* projects themselves are unchanged — they just have a new host.
7. Resilience — what happens when things go wrong
| Failure | Behaviour |
|---|---|
| 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. |
| 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. |
| RPC timeout (default 10s) | MessageBusTimeoutException → IUserLookup catches → empty result. Same graceful degradation. |
| 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). |
| 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. |
| 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. |
8. Tests (53 total)
cd SplitApp.Modular
dotnet test
| Project | Tests | Covers |
|---|---|---|
SplitApp.Modules.Users.Tests |
8 | IdentityHelpers — JWT generation + validation round-trips, reject malformed/expired/wrong-issuer/wrong-key/wrong-audience tokens |
SplitApp.Modules.Trips.Tests |
12 | LangStr — translation lookup, fallback chain, empty/null cases |
SplitApp.Modules.Expenses.Tests |
10 | CurrencyConverter — exchange rates, rounding, signs, round-trip precision |
SplitApp.Shared.Messaging.Tests |
9 | Wire-format round-trips for every integration message (GetUserByIdRequest, GetUsersByIdsRequest, UserDeletedEvent, UserDto), marker-interface invariants, RabbitMqTopology.HandlerQueueName stability |
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) |
Architecture tests in tests/SplitApp.WebApp.IntegrationTests/Architecture/:
ModuleBoundaryTests— no module'sApplication/Infrastructure/Apiproject references another module's project;Shared.*references no module.DbContextSchemaIsolationTests— eachDbContextexposesDbSet<T>only for entities in its own module'sDomain.CrossModuleNavigationTests— cross-module navigation properties must be[NotMapped].HostBootSmokeTests—WebApplicationFactory<Program>boots the host (no Users module in the WebApp anymore — proves the decoupling works at runtime).
A failing architecture test means someone violated an invariant — the build stops.
9. Deployment
Compose stack:
docker compose -p splitapp-phase4 up --build -d
Five containers:
| Service | Container | Host port | Notes |
|---|---|---|---|
webapp |
phase4-webapp |
http://localhost:97 | The MVC monolith |
users-service |
phase4-users-service |
http://localhost:98 | The Users microservice (own Swagger at /swagger) |
rabbitmq |
phase4-rabbitmq |
http://localhost:15672 (UI), :5672 (AMQP) |
Management UI: guest/guest |
db-monolith |
phase4-db-monolith |
(internal) | PostgreSQL 16 — Trips + Expenses tables |
db-users |
phase4-db-users |
(internal) | PostgreSQL 16 — Identity tables |
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.
Volumes: phase4-monolith-pgdata, phase4-users-pgdata, phase4-webapp-keys (the last preserves DataProtection keys across restarts so cookies + antiforgery survive).
CI/CD: .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).
10. Why microservices (over modular monolith)?
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:
| Aspect | Modular monolith | Microservices |
|---|---|---|
| Deployment | 1 container | 5 containers + healthcheck choreography |
| Inter-module call latency | Method call (nanoseconds) | RPC over RabbitMQ (~milliseconds) |
| Transactional consistency | One Postgres transaction across modules | Eventual consistency via events + cascade handlers |
| Independent deploy/scale | All-or-nothing | Each service can be scaled / deployed independently |
| Failure isolation | Crash takes everything | Users service crash leaves Trips + Expenses functional (with degraded user display) |
| Local dev | dotnet run |
docker compose up + cross-process logs |
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.