profileShare

rasmusjy / splitapp-backend-modular-monolith

Read-only snapshot

No repository description.

main default branch 418 files Expires Sep 13, 2026, 9:06 AM
ARCHITECTURE.md 12,983 bytes

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

Production: https://travel.rasmusj.com/

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 / Api project may reference: another project inside the same module, plus Shared.Kernel and Shared.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.
  • WebApp is the only project that references all three modules' Api and Infrastructure projects.

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> → schema users
  • TripsDbContext : DbContext → schema trips
  • ExpensesDbContext : DbContext → schema expenses

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. IsTripParticipantQuery before 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:

  1. Configures cross-cutting host concerns (MVC, API versioning via Asp.Versioning, Swagger, authorization).
  2. Calls services.AddUsersModule(...), services.AddTripsModule(...), services.AddExpensesModule(...) — each module's extension wires its own DbContext, repositories, services, and MediatR handlers.
  3. Registers controllers from each module's Api assembly via AddApplicationPart(...).
  4. 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:

  1. ModuleBoundaryTests — no module project has a <ProjectReference> to another module's Application/Infrastructure/Api project; no Shared.* project references a module.
  2. DbContextSchemaIsolationTests — each DbContext only exposes DbSet<T> for entities that live in its own Domain project (plus a tiny framework allowlist).
  3. 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.