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 15,543 bytes

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

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

  1. ModuleBoundaryTests — no module's Application/Infrastructure/Api project has a <ProjectReference> to another module's 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 — 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.
  4. HostBootSmokeTestsWebApplicationFactory<Program> boots the full host in Testing env (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.