SplitApp — Trip Expense Management (Microservices)
SplitApp is an ASP.NET Core 10.0 web application for managing group trips and splitting expenses. Users create trips, invite friends, track costs with flexible splitting (equal / equal-subset / exact / percentage), manage budgets, run polls, maintain a wishlist, and settle debts via an optimized algorithm.
This repo is the microservices implementation. It builds on the modular-monolith version (splitapp-backend-modular-monolith) by extracting the Users module into a separate microservice (SplitApp.UsersService) with its own database. The two processes communicate via RabbitMQ (inter-service RPC + events) and HTTP REST (login / register / admin CRUD). Runs locally via docker compose (webapp + users-service + RabbitMQ + two Postgres databases).
Run
docker compose -p splitapp-phase4 up --build -d
Brings up five containers:
| Service | Container | Host port | Notes |
|---|---|---|---|
webapp |
phase4-webapp |
http://localhost:97 | The MVC monolith (Trips + Expenses + Admin UX) |
users-service |
phase4-users-service |
http://localhost:98 | The Users microservice — Identity, JWT issuance, admin REST API |
rabbitmq |
phase4-rabbitmq |
http://localhost:15672 (UI), :5672 | Message broker (guest / guest) |
db-monolith |
phase4-db-monolith |
(internal) | PostgreSQL 16, splitapp database (Trips + Expenses) |
db-users |
phase4-db-users |
(internal) | PostgreSQL 16, splitapp_users database (Identity + RefreshTokens) |
Module migrations run automatically on each service's startup. The users-service seeds the 6 test users; the webapp skips example-trip seeding (the Users service doesn't expose seed data without admin auth — see "Known limitations" below).
Test login (after seed):
user@example.com,alice@example.com,bob@example.com,charlie@example.com,diana@example.com/Kala.12345— regular users. The password is in the source on purpose: this is a demo and the data is invented.- The
adminaccount is seeded only whenSEED_ADMIN_PASSWORDis set, and there is no default. Without the variable there is no administrator at all.
Stop:
docker compose -p splitapp-phase4 down # keep data volumes
docker compose -p splitapp-phase4 down -v # also drop the named volumes
Architecture at a glance
┌──────────────────────┐ 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 + exp. │ │ exchanges: │ │ Identity + │
│ │ │ splitapp. │ │ refresh tokens│
│ │ │ events │ │ │
│ │ │ splitapp. │ │ │
│ │ │ requests │ │ │
└───────────────┘ └──────────────┘ └───────────────┘
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 validate identically |
Reference rules:
SplitApp.WebApphas zero project references to anySplitApp.Modules.Users.*project.- Cross-process inter-module function calls go through RabbitMQ only (via the
IUserLookupabstraction inSplitApp.Shared.Messaging). Trips.Api+Expenses.Apicontrollers +AppUnitOfWorkuseIUserLookup(RabbitMQ RPC), not in-process MediatR, for user data.- Inside the monolith (Trips ↔ Expenses), MediatR is still used in-process for cross-module calls — only Users moved out of process.
See architecture.md for the full architecture deep-dive.
Solution layout
SplitApp.Modular/
├── SplitApp.sln
├── Directory.Build.props
├── src/
│ ├── SplitApp.WebApp/ ← monolith host (Trips + Expenses + MVC + Admin)
│ │ ├── Program.cs ← JWT bearer + cookie reader, AddMessaging, AddHttpClient<IUsersServiceClient>
│ │ ├── Application/
│ │ │ ├── Services/ (+ Admin/) ← lifted phase-2 BLL — IUsersServiceClient for user lookups
│ │ │ ├── DTO/ ← BllDtos (no AppUser refs anywhere)
│ │ │ ├── Mappers/ ← Domain ↔ BllDto factories (UserDto-based)
│ │ │ ├── Persistence/AppUnitOfWork.cs ← Trips + Expenses DbContexts, IUserLookup for user hydration
│ │ │ ├── Persistence/CrossModuleNavigationLoader.cs ← uses IUserLookup (RabbitMQ RPC)
│ │ │ ├── UsersService/ ← typed HttpClient + JwtForwardingHandler
│ │ │ └── Messaging/UserDeletedEventHandler.cs ← RabbitMQ event subscriber
│ │ ├── Controllers/AccountController.cs ← MVC login/register/logout (replaces Areas/Identity)
│ │ ├── Areas/Admin/ ← full admin UX, UsersController calls HTTP REST
│ │ └── Resources/ ← i18n .resx (EN + ET)
│ ├── Services/Users/SplitApp.UsersService/ ← NEW microservice host
│ │ ├── Program.cs ← AddUsersModule + MassMessaging consumers + Swagger
│ │ ├── Messaging/ ← GetUserByIdRequestHandler, GetUsersByIdsRequestHandler
│ │ ├── Controllers/HealthController.cs ← /health endpoint (gated on seed completion)
│ │ └── Hosting/ ← Swagger config + SeededHealthState
│ ├── Shared/
│ │ ├── SplitApp.Shared.Kernel/ ← BaseEntity, IBaseRepo, IUoW, LangStr, IdentityHelpers
│ │ ├── SplitApp.Shared.Contracts/ ← in-process MediatR contracts (Trips ↔ Expenses)
│ │ └── SplitApp.Shared.Messaging/ ← RabbitMQ wrapper, IUserLookup, integration messages
│ └── Modules/
│ ├── Users/ ← consumed by SplitApp.UsersService (NOT by WebApp)
│ ├── Trips/ ← in-process module in WebApp
│ └── Expenses/ ← in-process module in WebApp
└── tests/
├── SplitApp.Modules.Users.Tests/
├── SplitApp.Modules.Trips.Tests/
├── SplitApp.Modules.Expenses.Tests/
├── SplitApp.Shared.Messaging.Tests/ ← NEW: integration message contract tests
└── SplitApp.WebApp.IntegrationTests/ ← architecture invariants + HTTP smoke
The Modules/Users/* projects still exist in the modular monolith layout — but they are consumed only by the new SplitApp.UsersService host. The SplitApp.WebApp.csproj has zero <ProjectReference> entries pointing at any Users.* project.
URL map
Webapp (port 97)
| URL | Purpose |
|---|---|
/ |
Landing page (MVC) |
/Account/{Login, Register, Logout, Manage} |
MVC auth (HTTP → Users service → JWT cookie) |
/Trips, /Trips/{Create,Details/{id},Edit/{id},Delete/{id}} |
Trip CRUD |
/Members?tripId={id} and /Members/AcceptInvitation/{token} |
Trip participants + invitation flow |
/Expenses?tripId={id} (with Create/Edit/Delete) |
Trip expenses |
/Budget?tripId={id} (CreateCategory/EditCategory/DeleteCategory) |
Budget categories |
/Settlement?tripId={id} |
Balances + settlement plans |
/PollsClient?tripId={id} (Create/Details) |
Trip polls |
/WishlistClient?tripId={id} |
Trip wishlist |
/Admin/Dashboard |
Admin home (admin role) |
/Admin/Users |
Admin user list — proxies to users-service via HTTP |
/Admin/{Trips, Expenses, BudgetCategories, Currencies, Invitations, Polls, SettlementPlans, SettlementPayments, SplitPresets, TripParticipants, Wishlist} |
Admin CRUD per entity |
/swagger |
Swagger UI — Trips + Expenses REST only |
Users service (port 98)
| URL | Purpose |
|---|---|
/swagger |
Swagger UI for all Users REST endpoints |
/health |
Healthcheck (200 only after migrate + seed completes) |
/api/v1/identity/account/{register, login, logout, refreshtokendata} |
Auth (anonymous) |
/api/v1/identity/admin/users (GET/PUT/DELETE/+ /{id}/roles, /admin/roles) |
Admin user CRUD ([Authorize(Roles = "admin")]) |
RabbitMQ management (port 15672)
- Login
guest/guest - Exchanges:
splitapp.events(topic),splitapp.requests(direct) - Queues:
q.webapp.UserDeletedEvent,q.users-service.GetUserByIdRequest,q.users-service.GetUsersByIdsRequest
Inter-service messaging
All cross-service communication goes through the SplitApp.Shared.Messaging library. Two patterns:
| Pattern | Used for | Library API |
|---|---|---|
| Pub/Sub (events) | Fire-and-forget fan-out (UserDeletedEvent) |
IMessageBus.PublishEventAsync<T> + IEventHandler<T> |
| RPC (requests) | Synchronous data lookups (GetUserByIdRequest, GetUsersByIdsRequest) |
IMessageBus.RequestAsync<TReq, TResp> + IRequestHandler<TReq, TResp> |
For the WebApp, IUserLookup is the high-level facade over the bus — controllers and services consume IUserLookup, not IMessageBus directly.
In-process MediatR is still used between Trips ↔ Expenses modules (they live in the same process). Only the Users module crossed the process boundary.
Tests
cd SplitApp.Modular
dotnet test
53 tests across five projects, all passing:
| Project | Tests | Covers |
|---|---|---|
SplitApp.Modules.Users.Tests |
8 | IdentityHelpers — JWT generation/validation round-trips |
SplitApp.Modules.Trips.Tests |
12 | LangStr — multi-language fallback, edge cases |
SplitApp.Modules.Expenses.Tests |
10 | CurrencyConverter — exchange rates, rounding |
SplitApp.Shared.Messaging.Tests |
9 | Wire-format contract round-trips (GetUserByIdRequest, UserDeletedEvent, UserDto, …) + marker interface invariants + queue naming |
SplitApp.WebApp.IntegrationTests |
14 | Architecture invariants (module boundaries, schema isolation) + WebApplicationFactory<Program> HTTP smoke |
Phase 4 ↔ Phase 3 mapping
| Phase 3 (modular monolith) | Phase 4 (microservices) |
|---|---|
1 process (SplitApp.WebApp) |
2 processes (SplitApp.WebApp + SplitApp.UsersService) |
1 Postgres database, 3 schemas (users/trips/expenses) |
2 Postgres databases (splitapp + splitapp_users) |
| In-process MediatR for all cross-module calls | RabbitMQ (Users.*) + in-process MediatR (Trips/Expenses) |
UsersDbContext registered in WebApp host |
UsersDbContext registered in SplitApp.UsersService only |
Areas/Identity scaffolded Razor Pages handle login/register |
New Controllers/AccountController.cs posts to Users service via HTTP, stores JWT in HttpOnly cookie |
Admin/UsersController uses UserManager<AppUser> directly |
Admin/UsersController uses typed IUsersServiceClient HTTP client |
[NotMapped] AppUser? CreatedBy on Trip/Expense entities |
[NotMapped] UserDto? CreatedBy — entities no longer reference Users domain types |
~13 WebApp files inject UserManager/SignInManager/RoleManager |
All replaced with User.FindFirstValue(ClaimTypes.NameIdentifier) claim reads |
| 44 tests | 53 tests (added Shared.Messaging.Tests) |
One container deploy (docker compose -p splitapp-phase3 up) |
Five-container compose with healthcheck-gated startup |
Known limitations
- No example trip data on first boot.
AppDataInit.SeedExampleDatacallsIUsersServiceClient.ListUsersAsync()without a JWT (it runs from startup, not an HTTP request context). The Users service returns 401, thetry/catchswallows it, and example trips don't get seeded. Core user accounts (admin, alice, …) ARE seeded by the Users service itself, so login works — you just create your own trips from scratch. - Graceful degradation if RabbitMQ is unavailable.
IUserLookupcatchesMessageBusTimeout/MessageBusUnavailableExceptionand returns empty results, so pages render with blank user names rather than crashing. Logs show the warning.
Files & docs
- architecture.md — architecture deep-dive
- SplitApp.Modular/README.md — solution-level README