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

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 via JwtForwardingHandler).
  • 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.events topic exchange and cleans up TripParticipant + ExpenseSplit rows.

4. The Shared.Messaging library

Custom-built wrapper around RabbitMQ.Client v7 (raw client — no MassTransit / NServiceBus / etc.). Provides:

Public surface

  • IMessageBusPublishEventAsync<T> + RequestAsync<TReq, TResp>(timeout)
  • IUserLookup — high-level facade so consumers don't take a direct dep on IMessageBus (only knows about UserDto)
  • IIntegrationEvent + IIntegrationRequest<TResponse> — marker interfaces
  • IEventHandler<T> + IRequestHandler<TReq, TResp> — handler interfaces, DI-resolved per delivery in a scope
  • AddMessaging(IConfiguration, serviceName) + AddIntegrationEventHandler<T, THandler>() + AddIntegrationRequestHandler<TReq, TResp, THandler>()

Internals

  • 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.
  • 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.
  • 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.
  • RabbitMqTopology — two exchanges declared idempotently: splitapp.events (topic, durable) and splitapp.requests (direct, durable). Routing keys = CLR type names.

Wire format

JSON, system properties on the AMQP envelope:

  • Type = message type name
  • CorrelationId = 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) MessageBusTimeoutExceptionIUserLookup 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/:

  1. ModuleBoundaryTests — no module's Application/Infrastructure/Api project references another module's project; Shared.* references no module.
  2. DbContextSchemaIsolationTests — each DbContext exposes DbSet<T> only for entities in its own module's Domain.
  3. CrossModuleNavigationTests — cross-module navigation properties must be [NotMapped].
  4. HostBootSmokeTestsWebApplicationFactory<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.