SplitApp — Modular Monolith (Phase 3)
This is the phase-3 refactor of SplitApp from a Clean/Onion monolith (phase 2) to a modular monolith. One deployable, three internally isolated modules, MediatR for cross-module communication.
Deployment
Deployment: runs locally via Docker Compose (see the repo root README).
The repo's root docker-compose.yml + Dockerfile build phase 3 locally:
| Service | Container | Builds from | Host port | Notes |
|---|---|---|---|---|
phase3 |
phase3 |
./Dockerfile (root) |
90 |
Phase 3 — modular monolith |
db |
phase3-db |
postgres:16 |
(internal only) | PostgreSQL with schemas users / trips / expenses — not exposed to host |
Bring everything up:
docker compose up --build
The container exposes http://localhost:90 (host port 90 → container port 8080). Per-module migrations run automatically on startup.
Solution layout
SplitApp.sln
├── src/
│ ├── SplitApp.WebApp/ // composition root + admin Area + host
│ ├── Shared/
│ │ ├── SplitApp.Shared.Kernel/ // BaseEntity, IBaseRepository, IUnitOfWork, LangStr, IdentityHelpers
│ │ └── SplitApp.Shared.Contracts/ // MediatR IRequest / INotification contracts
│ └── Modules/
│ ├── Users/
│ │ ├── SplitApp.Modules.Users.Domain/ // AppUser, AppRole, AppRefreshToken
│ │ ├── SplitApp.Modules.Users.Application/ // IIdentityService + JWT/refresh logic, MediatR handlers
│ │ ├── SplitApp.Modules.Users.Infrastructure/ // UsersDbContext (schema "users"), repos, AddUsersModule
│ │ └── SplitApp.Modules.Users.Api/ // /api/v1/identity/... controllers + DTOs
│ ├── Trips/ // same 4-project layout, schema "trips"
│ └── Expenses/ // same 4-project layout, schema "expenses"
└── tests/
├── SplitApp.Modules.Users.Tests/
├── SplitApp.Modules.Trips.Tests/
├── SplitApp.Modules.Expenses.Tests/
└── SplitApp.WebApp.IntegrationTests/ // architecture + integration tests
The reference rules
Compiler-enforced via <ProjectReference> graph and verified by tests/SplitApp.WebApp.IntegrationTests/Architecture/ModuleBoundaryTests.cs:
- A module's
Application/Infrastructure/Apiproject may reference: another project inside the same module, plusShared.KernelandShared.Contracts. Nothing else. - Inter-module function calls go through MediatR only — never via a direct
<ProjectReference>to another module's services or repositories. Shared.*projects may not reference any module.WebAppis the only project that references all three modules'ApiandInfrastructureprojects.
Caveat at the Domain level only: to keep phase 2's view-rendering parity (mappers that read Trip.DefaultCurrency.Code, TripParticipant.User.FirstName, etc.), the entity classes still declare those navigation properties — annotated [NotMapped] so EF never crosses schemas. Keeping the property types on the entities required adding three Domain-to-Domain <ProjectReference>s:
Modules/Trips/SplitApp.Modules.Trips.Domain
→ Modules/Users/SplitApp.Modules.Users.Domain
→ Modules/Expenses/SplitApp.Modules.Expenses.Domain
Modules/Expenses/SplitApp.Modules.Expenses.Domain
→ Modules/Users/SplitApp.Modules.Users.Domain
This bends the strict "no direct references between modules" rule from phase3.md at the Domain level. Application, Infrastructure, and Api projects remain isolated and use MediatR for actual function calls — only entity types are shared. The CrossModuleNavigationTests invariant enforces that any cross-module navigation is [NotMapped]; a plain mapped nav across schemas would fail the build.
Inter-module communication
All cross-module calls go through MediatR. Contracts live in SplitApp.Shared.Contracts/<Module>/{Queries|Events}/ 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.
Currently shipped contracts:
| Contract | Owner module | 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 | Used by ExpensesController for IDOR + payer validation |
TripDeletedEvent : INotification |
Trips | Expenses subscribes to delete dependent expenses/settlements |
GetTripExpenseTotalsQuery : IRequest<TripExpenseTotalsDto> |
Expenses | |
GetBudgetCategorySpentQuery : IRequest<IReadOnlyDictionary<Guid, decimal>> |
Expenses | Per-budget-category spent totals — used by Trips' BudgetCategoriesController |
ExpenseSettledEvent : INotification |
Expenses | Reserved for future use |
SettlementPlanCompletedEvent : INotification |
Expenses | Trips subscribes to advance "Finalizing" trips to "Settled" once every payment is confirmed |
Data 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. Schemas — not separate databases — provide isolation. Cross-module SQL joins are forbidden; cross-module data is composed at the application layer via MediatR.
Cross-module entity references are bare Guid fields with no navigation properties and no EF foreign-key constraints (e.g. Trip.CreatedById : Guid references users.AspNetUsers.Id only conceptually, not via FOREIGN KEY). Referential integrity is maintained by:
- Up-front MediatR validation queries (e.g.
IsTripParticipantQuerybefore persisting an expense split). - Domain-event cleanup on delete (
UserDeletedEvent,TripDeletedEvent).
Per-module migrations
Each module ships its own EF migration history table inside its own schema. Generate new migrations against the module-specific DbContext + project, with WebApp as the startup project (so the connection string is read from appsettings.json):
# Users
dotnet ef migrations add <Name> -c UsersDbContext \
-p src/Modules/Users/SplitApp.Modules.Users.Infrastructure/SplitApp.Modules.Users.Infrastructure.csproj \
-s src/SplitApp.WebApp/SplitApp.WebApp.csproj \
-o Persistence/Migrations
# Trips
dotnet ef migrations add <Name> -c TripsDbContext \
-p src/Modules/Trips/SplitApp.Modules.Trips.Infrastructure/SplitApp.Modules.Trips.Infrastructure.csproj \
-s src/SplitApp.WebApp/SplitApp.WebApp.csproj \
-o Persistence/Migrations
# Expenses
dotnet ef migrations add <Name> -c ExpensesDbContext \
-p src/Modules/Expenses/SplitApp.Modules.Expenses.Infrastructure/SplitApp.Modules.Expenses.Infrastructure.csproj \
-s src/SplitApp.WebApp/SplitApp.WebApp.csproj \
-o Persistence/Migrations
Migrations are applied automatically on startup by each module's UseXxxModule(IApplicationBuilder) extension (called from Program.cs).
Composition root
SplitApp.WebApp/Program.cs is the only place that sees all three modules. It:
- Configures cross-cutting host concerns (MVC, API versioning via
Asp.Versioning, Swagger, authorization). - Calls
services.AddUsersModule(...),services.AddTripsModule(...),services.AddExpensesModule(...)— each module's extension wires its ownDbContext, repositories, services, and MediatR handlers. - Registers controllers from each module's
Apiassembly viaAddApplicationPart(...). - Calls
app.UseUsersModule(),app.UseTripsModule(),app.UseExpensesModule()— each applies its module's pending migrations.
The composition root does not call AddDbContext<...> directly, does not register any module-internal repository/service, and does not scan module assemblies for MediatR. Each module owns its own registration.
Phase 2 → Phase 3 mapping
| 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 |
WebApp.ApiControllers.Identity.* |
SplitApp.Modules.Users.Api.Controllers |
WebApp.ApiControllers.{TripsController,...} |
SplitApp.Modules.Trips.Api.Controllers |
WebApp.ApiControllers.{ExpensesController,...} |
SplitApp.Modules.Expenses.Api.Controllers |
Composition root: MVC + Razor + Admin
The WebApp hosts the full phase 2 UI surface (101 Razor views, 21 MVC + Admin controllers, identity Razor pages). It is the only place that sees all three modules. Phase 2 is preserved by lifting the BLL layer into src/SplitApp.WebApp/Application/ under three buckets:
- DTO — phase 2 BLL DTOs unchanged (POCOs; views model-bind to these)
- Mappers — entity ↔ DTO factories, unchanged structurally; cross-module nav fields populate from
[NotMapped]properties - Services + Services/Admin + Services/Identity — phase 2 BLL services unchanged; they consume
IAppUnitOfWork
Application/Persistence/AppUnitOfWork.cs is the composition-root facade: it injects all three module DbContexts (UsersDbContext, TripsDbContext, ExpensesDbContext) and exposes them through phase 2's IAppUnitOfWork interface. Each repository routes to its module's DbContext — schemas stay isolated, but the host can compose across them.
Application/Persistence/CrossModuleNavigationLoader.cs hydrates the [NotMapped] cross-module nav properties (Trip.DefaultCurrency, TripParticipant.User, Expense.PaidByUser, etc.) after entities are loaded. EF never crosses schemas; the loader pulls cross-module data via a separate query against the appropriate module's DbContext.
MVC client controllers
Controllers/{Home, Trips, Expenses, Budget, Members, Settlement, PollsClient, WishlistClient}Controller — the user-facing site. Cookie-authenticated.
Admin area
Areas/Admin/Controllers/{Dashboard, Users, Trips, Expenses, BudgetCategories, Currencies, Invitations, Polls, SettlementPayments, SettlementPlans, SplitPresets, TripParticipants, Wishlist}Controller — admin CRUD per entity, gated on the admin role.
Identity Razor page
Areas/Identity/Pages/Account/Register — cookie-based registration. JWT flows are handled by Modules.Users.Api.AccountController.
Migrations are skipped when ASPNETCORE_ENVIRONMENT=Testing so WebApplicationFactory<Program> can boot without a real Postgres instance — see tests/SplitApp.WebApp.IntegrationTests/HostBootSmokeTests.cs.
Architecture tests
tests/SplitApp.WebApp.IntegrationTests/Architecture/ runs as part of dotnet test and asserts:
ModuleBoundaryTests— no module project has a<ProjectReference>to another module's Application/Infrastructure/Api project; noShared.*project references a module.DbContextSchemaIsolationTests— eachDbContextonly exposesDbSet<T>for entities that live in its own Domain project (plus a tiny framework allowlist).CrossModuleNavigationTests— Domain entities may declare navigation properties to another module's entity type only when annotated[NotMapped]. EF never loads these — the WebApp facade hydrates them in-memory after fetching from the appropriate module's DbContext.
A failing test means a developer just violated the modular-monolith invariant.