SplitApp — Phase 3 Modular Monolith
ASP.NET Core 10 modular monolith for splitting trip expenses among participants. Three modules (Users, Trips, Expenses) communicating via MediatR; one deployable; per-module Postgres schemas. Full phase-2 UI surface preserved (101 Razor views, 21 MVC + Admin controllers, identity Razor pages, 10 REST API controllers).
See docs/ARCHITECTURE.md for layout, reference rules, contracts, per-module migration commands, and the [NotMapped] cross-module navigation caveat.
Run
With Docker (recommended)
From the repo root (one level up from this directory):
docker compose up --build
Deployment: runs locally via Docker Compose (see the repo root README).
Locally, phase 3 listens on http://localhost:90 (host port 90 → container port 8080). Each module's migrations are applied automatically on startup. The container is named phase3. Postgres uses dedicated schemas users / trips / expenses inside the same splitapp database (DB is internal-only — not exposed to host).
The single Dockerfile lives at the repo root and copies from SplitApp.Modular/. There is no separate Dockerfile inside this directory — the root one is canonical.
Without Docker
docker compose up -d db
dotnet run --project src/SplitApp.WebApp
By default dotnet run uses https://localhost:7133 / http://localhost:5297 (see Properties/launchSettings.json).
URL map
| URL | Purpose |
|---|---|
/ |
Landing page (MVC) |
/Trips, /Trips/Create, /Trips/Details/{id}, /Trips/Edit/{id}, /Trips/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} (with CreateCategory/EditCategory/DeleteCategory) |
Budget categories per trip |
/Settlement?tripId={id} |
Balances + settlement plans |
/PollsClient?tripId={id} (Create/Details) |
Trip polls |
/WishlistClient?tripId={id} (Create/Edit/Delete) |
Trip wishlist |
/Identity/Account/Register |
Cookie-based register |
/Admin/Dashboard |
Admin home (admin role) |
/Admin/{Users, Trips, Expenses, BudgetCategories, Currencies, Invitations, Polls, SettlementPlans, SettlementPayments, SplitPresets, TripParticipants, Wishlist} |
Admin CRUD over each entity |
/swagger |
Swagger UI listing every module's REST endpoints |
REST API
| Module | Endpoints |
|---|---|
| Users | /api/v1/identity/account/{register, login, logout, refreshtokendata} |
| Trips | /api/v1/trips, /api/v1/budgetcategories, /api/v1/invitations, /api/v1/polls, /api/v1/wishlist |
| Expenses | /api/v1/expenses, /api/v1/currencies, /api/v1/settlements, /api/v1/splitpresets |
Tests
dotnet test
25 tests across four projects:
- Per-module unit tests —
CurrencyConverter(5),LangStr(7),IdentityHelpersJWT round-trips (4) - Architecture invariants (in
tests/SplitApp.WebApp.IntegrationTests/Architecture/):ModuleBoundaryTests— no module'sApplication/Infrastructure/Apimay<ProjectReference>another moduleDbContextSchemaIsolationTests— everyDbContextexposesDbSet<T>only for entities in its ownDomainprojectCrossModuleNavigationTests— cross-module navigation properties allowed only when[NotMapped]; the WebApp facade hydrates them in-memory after loading from the owning module's DbContext, so EF never crosses schemas
- Smoke —
WebApplicationFactory<Program>boots the full host inTestingenv (skipping migrations) and serves/,/Home/Index, returns 401 on unauthenticated API hit
A failing architecture test means a developer just violated the modular-monolith invariant.
Module owners
| Module | Owns | Schema |
|---|---|---|
| Users | Identity, JWT issuance, refresh tokens, user profile | users |
| Trips | Trips, participants, invitations, polls, wishlists, budget categories | trips |
| Expenses | Expenses, splits, settlement plans, settlement payments, split presets, currencies | expenses |
Caveat — [NotMapped] cross-module navigation properties
To preserve phase 2's view-rendering parity (BLL DTO factories that read Trip.DefaultCurrency.Code, TripParticipant.User.FirstName, etc.) the entity classes still declare those navigation properties — annotated [NotMapped] so EF never crosses schemas. The CrossModuleNavigationLoader in src/SplitApp.WebApp/Application/Persistence/ populates them in C# after entity load by querying the appropriate module's DbContext.
Keeping the property types on the entities required adding <ProjectReference> between Domain projects:
Modules/Trips/SplitApp.Modules.Trips.Domain
→ Modules/Users/SplitApp.Modules.Users.Domain (for AppUser refs)
→ Modules/Expenses/SplitApp.Modules.Expenses.Domain (for Currency refs)
Modules/Expenses/SplitApp.Modules.Expenses.Domain
→ Modules/Users/SplitApp.Modules.Users.Domain (for AppUser refs)
This bends the strict "no direct references between modules" rule from phase3.md at the Domain level. Application, Infrastructure, and Api projects remain isolated — they never <ProjectReference> another module — and continue to use MediatR for actual function calls. Schema isolation, MediatR-only inter-module communication, and per-module DbContext ownership are all preserved at runtime; only the entity type system is shared.