Architecture — SplitApp Phase 3 (Modular Monolith)
This document is the architectural deep-dive for the Phase 3 refactor. The whole product lives under SplitApp.Modular/: one deployable, three internally isolated modules (Users, Trips, Expenses), MediatR for cross-module communication, schema-per-module Postgres isolation.
For the original course context, see also modularmonolith.md and phase3.md. The module-level README at SplitApp.Modular/README.md and the dedicated SplitApp.Modular/docs/ARCHITECTURE.md are the canonical references; this file is the high-level overview.
1. The picture
┌────────────────────────────────────────────────┐
│ SplitApp.WebApp (host) │
│ Program.cs · Controllers · Areas/Admin │
│ Application/{Services, DTO, Mappers, │
│ Persistence} │
└────────────────────────────────────────────────┘
│ │ │
▼ ▼ ▼
┌──────────┐ ┌──────────┐ ┌──────────┐
│ Users │ │ Trips │ │ Expenses │
│ Domain │ │ Domain │ │ Domain │
│ App │ │ App │ │ App │
│ Infra │ │ Infra │ │ Infra │
│ Api │ │ Api │ │ Api │
│ schema: │ │ schema: │ │ schema: │
│ users │ │ trips │ │ expenses │
└──────────┘ └──────────┘ └──────────┘
▲ ▲ ▲
└─MediatR───┴─MediatR───┘
┌──────────────────────┐ ┌──────────────────────┐
│ Shared.Contracts │ │ Shared.Kernel │
│ IRequest/INotification│ │ BaseEntity, LangStr │
└──────────────────────┘ └──────────────────────┘
Each module = mini-Clean-Architecture (Domain ← Application ← Infrastructure, Api for REST). Each module owns its own DbContext scoped to its own Postgres schema. Cross-module function calls go through MediatR only — no direct <ProjectReference> between modules' Application / Infrastructure / Api layers.
2. Module layout
SplitApp.Modular/
├── SplitApp.sln
├── Directory.Build.props
├── src/
│ ├── SplitApp.WebApp/ ← composition root, host, admin Area
│ │ ├── Program.cs ← AddXxxModule(...) wiring
│ │ ├── Application/ ← lifted phase-2 BLL
│ │ │ ├── Services/ (+ Admin/, Identity/)
│ │ │ ├── DTO/ ← BllDtos
│ │ │ ├── Mappers/ ← Domain ↔ BllDto factory mappers
│ │ │ ├── Persistence/AppUnitOfWork.cs ← aggregates 3 module DbContexts
│ │ │ └── Persistence/CrossModuleNavigationLoader.cs
│ │ ├── Areas/Admin/, Areas/Identity/, Controllers/, Views/, Resources/
│ ├── Shared/
│ │ ├── SplitApp.Shared.Kernel/ ← BaseEntity, IBaseRepo, IUoW, LangStr, IdentityHelpers
│ │ └── SplitApp.Shared.Contracts/ ← MediatR IRequest / INotification
│ └── Modules/
│ ├── Users/
│ │ ├── SplitApp.Modules.Users.Domain/ ← AppUser, AppRole, AppRefreshToken
│ │ ├── SplitApp.Modules.Users.Application/ ← IIdentityService + JWT/refresh, MediatR handlers
│ │ ├── SplitApp.Modules.Users.Infrastructure/ ← UsersDbContext (schema "users"), repos, AddUsersModule
│ │ └── SplitApp.Modules.Users.Api/ ← /api/v1/identity/...
│ ├── Trips/ ← same 4-project layout, schema "trips"
│ └── Expenses/ ← same 4-project layout, schema "expenses"
└── tests/
├── SplitApp.Modules.{Users,Trips,Expenses}.Tests/ ← per-module unit tests
└── SplitApp.WebApp.IntegrationTests/ ← architecture invariants + smoke
3. Reference rules (compiler-enforced + arch-test verified)
| Source | Allowed targets | Notes |
|---|---|---|
Modules/X/Application |
same-module Domain + Shared.Kernel + Shared.Contracts |
|
Modules/X/Infrastructure |
same-module Domain + Application + Shared.Kernel |
|
Modules/X/Api |
same-module Application + Shared.Kernel + Shared.Contracts |
|
Shared.* |
none of the modules | |
WebApp |
all 3 modules' Api + Infrastructure + Shared.* |
composition root |
Domain-level caveat: to keep view-rendering parity from phase 2 (mappers reading Trip.DefaultCurrency.Code, TripParticipant.User.FirstName, etc.), entity classes still declare cross-module navigation properties — annotated [NotMapped] so EF never crosses Postgres schemas. To make those property types compile, three Domain-to-Domain <ProjectReference>s exist:
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, Api projects remain isolated and use MediatR for actual function calls — only entity types are shared. Schema isolation, MediatR-only inter-module function calls, and per-module DbContext ownership are all preserved at runtime.
CrossModuleNavigationTests enforces the [NotMapped] rule: a mapped navigation across modules (where EF would actually try to traverse) fails the build.
4. Inter-module communication (MediatR)
Cross-module calls go through MediatR. Contracts live in SplitApp.Shared.Contracts/<Module>/{Queries|Events|Commands}/ and are records implementing IRequest<T> (sync queries/commands) or INotification (fan-out events). Handlers live in the owning module's Application or Infrastructure layer.
| Contract | Owner | Notes |
|---|---|---|
GetUserByIdQuery : IRequest<UserDto?> |
Users | Used by Trips/Expenses for display-name lookup |
GetUsersByIdsQuery : IRequest<IReadOnlyList<UserDto>> |
Users | Batch lookup |
UserDeletedEvent : INotification |
Users | Trips + Expenses subscribe to clean up rows |
GetTripByIdQuery : IRequest<TripSummaryDto?> |
Trips | Cross-module trip lookup |
GetTripParticipantsQuery : IRequest<IReadOnlyList<TripParticipantDto>> |
Trips | |
IsTripParticipantQuery : IRequest<bool> |
Trips | IDOR guard in ExpensesController |
TripDeletedEvent : INotification |
Trips | Expenses subscribes to delete dependent expenses/settlements |
GetTripExpenseTotalsQuery : IRequest<TripExpenseTotalsDto> |
Expenses | Per-currency totals for a trip |
GetBudgetCategorySpentQuery : IRequest<IReadOnlyDictionary<Guid, decimal>> |
Expenses | Per-budget-category spent totals |
ExpenseSettledEvent : INotification |
Expenses | Reserved for future use |
SettlementPlanCompletedEvent : INotification |
Expenses | Trips subscribes to advance "Finalizing" trips to "Settled" once every payment is confirmed |
5. Schema isolation
Each module owns its own DbContext:
UsersDbContext : IdentityDbContext<AppUser, AppRole, Guid>→ schemausersTripsDbContext : DbContext→ schematripsExpensesDbContext : DbContext→ schemaexpenses
All three connect to the same physical Postgres database via the same ConnectionStrings:DefaultConnection. Cross-module SQL joins are forbidden — composition happens at the application layer through MediatR or the WebApp facade's CrossModuleNavigationLoader.
Cross-module entity references are bare Guid fields with no EF foreign-key constraints (e.g. Trip.CreatedById : Guid references users.AspNetUsers.Id only conceptually). Referential integrity is maintained by:
- Up-front MediatR validation queries (e.g.
IsTripParticipantQuerybefore persisting an expense split) - Domain-event cleanup on delete (
UserDeletedEvent,TripDeletedEvent)
6. Composition root and lifted phase 2 BLL
SplitApp.WebApp is the only project that sees all three modules. It hosts the full phase 2 UI surface (101 Razor views, 21 MVC + Admin controllers, 10 REST API controllers, Identity Razor Register page) and lifts the entire phase 2 BLL layer into SplitApp.WebApp/Application/ under three buckets:
| Bucket | Contents |
|---|---|
Application/DTO |
Phase-2 BLL DTOs (TripBllDto, ExpenseBllDto, …) — POCOs that views model-bind to |
Application/Mappers |
Entity ↔ BLL DTO factory mappers |
Application/Services (+ Services/Admin, Services/Identity) |
Phase-2 BLL services lifted unchanged; consume IAppUnitOfWork |
Application/Persistence/AppUnitOfWork.cs |
Composition-root facade aggregating UsersDbContext, TripsDbContext, ExpensesDbContext behind phase-2's IAppUnitOfWork interface — each repository routes to the appropriate module's DbContext |
Application/Persistence/CrossModuleNavigationLoader.cs |
Hydrates [NotMapped] cross-module nav properties (Trip.DefaultCurrency, TripParticipant.User, Expense.PaidByUser, …) after entity load by querying the owning module's DbContext separately. EF never crosses schemas. |
The lifted BLL services keep working unchanged: _uow.Trips.GetByIdAsync(...) behaves like phase 2. In reality AppUnitOfWork.Trips routes the query to TripsDbContext, and CrossModuleHydration fills cross-module navs (Trip.CreatedBy, Trip.DefaultCurrency) afterwards via separate single-shot batched queries against the foreign module's DbContext.
7. Phase 3 → phase 2 mapping (for context)
| Phase 2 project | Phase 3 destination |
|---|---|
Base.Domain (BaseEntity, LangStr) |
SplitApp.Shared.Kernel |
Base.Contracts (IBaseEntity, IBaseRepository, IUnitOfWork) |
SplitApp.Shared.Kernel |
Base.Helpers (IdentityHelpers) |
SplitApp.Shared.Kernel.Auth |
App.Domain.Identity.* |
SplitApp.Modules.Users.Domain.Entities |
App.Domain.{Trip, TripParticipant, …} |
SplitApp.Modules.Trips.Domain.Entities |
App.Domain.{Expense, SettlementPlan, …, Currency} |
SplitApp.Modules.Expenses.Domain.Entities |
App.DAL.EF.AppDbContext |
Split into 3 XxxDbContext per module |
App.BLL.Services.Identity.* |
SplitApp.Modules.Users.Application.Services |
App.BLL.Services.* (Trip, Expense, Settlement, …) |
Lifted into SplitApp.WebApp/Application/Services (composition-root facade over the 3 module UoWs) |
App.BLL.DTO.*, App.BLL.Mappers.* |
Lifted into SplitApp.WebApp/Application/{DTO, Mappers} |
WebApp.ApiControllers.Identity.* |
SplitApp.Modules.Users.Api.Controllers |
WebApp.ApiControllers.{TripsController, …} |
SplitApp.Modules.Trips.Api.Controllers |
WebApp.ApiControllers.{ExpensesController, CurrenciesController, SettlementsController, SplitPresetsController} |
SplitApp.Modules.Expenses.Api.Controllers |
WebApp/Controllers/* (MVC client) + WebApp/Areas/Admin/* + WebApp/Areas/Identity/* + WebApp/Views/* |
SplitApp.Modular/src/SplitApp.WebApp/{Controllers, Areas/Admin, Areas/Identity, Views} (preserved structurally; namespace re-rooted to SplitApp.WebApp.*) |
8. Architecture tests (run on dotnet test)
tests/SplitApp.WebApp.IntegrationTests/Architecture/:
ModuleBoundaryTests— no module'sApplication/Infrastructure/Apiproject has a<ProjectReference>to another module's project; noShared.*project references a module.DbContextSchemaIsolationTests— eachDbContextonly exposesDbSet<T>for entities that live in its ownDomainproject (plus a tiny framework allowlist).CrossModuleNavigationTests— cross-module navigation properties are allowed only when annotated[NotMapped]. A plain mapped nav across modules (which would let EF cross schemas) fails the build.HostBootSmokeTests—WebApplicationFactory<Program>boots the full host inTestingenv (skipping per-module migrations) and verifies/,/Home/Index, and a 401 on unauthenticated API hits.
A failing architecture test means a developer just violated the modular-monolith invariant.
Total test count: 25 — Expenses (5) + Trips (7) + Users (4) + IntegrationTests (9). All passing on the current build.
9. Deployment
Production deployment: https://travel.rasmusj.com/
The repo's root Dockerfile + docker-compose.yml build and run phase 3 locally:
docker compose up --build
| Service | Container | Port | Notes |
|---|---|---|---|
phase3 |
phase3 |
http://localhost:90 | Web app (host port 90 → container port 8080) |
db |
phase3-db |
(internal only) | PostgreSQL 16, schemas users / trips / expenses — not exposed to host |
Per-module migrations run automatically on host startup. Sample data is seeded if DataInitialization:SeedData=true.
For per-module migration commands (dotnet ef migrations add ...) and the full URL map, see SplitApp.Modular/docs/ARCHITECTURE.md and SplitApp.Modular/README.md.
10. Why a modular monolith?
| Approach | Problem |
|---|---|
| Classic monolith | Everything references everything — one change cascades |
| Microservices | Distributed-systems pain — network, serialization, eventual consistency, deployment complexity |
| Modular monolith | Microservice-style boundaries + monolith deployment simplicity |
Future extraction path, if needed: Classic monolith → Modular monolith → Microservices. Each module already has its own schema, its own contracts, its own DbContext. Extracting a module into a separate service later means replacing in-process MediatR calls with HTTP/gRPC and domain events with a message broker — the code structure barely changes, only the transport layer.
See modularmonolith.md for the course material on the pattern.