SplitApp — Trip Expense Management (Phase 3 — Modular Monolith)
URL: https://travel.rasmusj.com/
SplitApp is an ASP.NET Core 10.0 web application for managing group trips and splitting expenses. Users create trips, invite friends, track costs with flexible splitting (equal / equal-subset / exact / percentage), manage budgets, run polls, maintain a wishlist, and settle debts via an optimized algorithm.
This repo is the Phase 3 — Modular Monolith refactor of the project. 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.
Built for the TalTech "Web Applications with C#" course Personal Project — Phase 3.
Run
docker compose up --build
Brings up two containers:
| Service | Container | Port | Notes |
|---|---|---|---|
phase3 |
phase3 |
http://localhost:90 | The web app |
db |
phase3-db |
(internal only) | PostgreSQL 16, schemas users / trips / expenses — not exposed to host |
Module migrations run automatically on host startup. Sample data is seeded if DataInitialization:SeedData=true (set in appsettings.json).
Test login (after seed):
admin@taltech.ee/Foo.Bar.1—adminrole, full Admin area accessalice@taltech.ee,bob@taltech.ee,charlie@taltech.ee,diana@taltech.ee/Foo.Bar.1— regular users with sample trips
Stop:
docker compose down
Architecture at a glance
┌────────────────────────────────────────────────┐
│ 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 │
└──────────────────────┘ └──────────────────────┘
Reference rules (compiler-enforced + verified by tests/SplitApp.WebApp.IntegrationTests/Architecture/):
- A module's
Application/Infrastructure/Apican reference: same-module projects +Shared.Kernel+Shared.Contracts. Nothing else. - Inter-module function calls go through MediatR only.
Shared.*may not reference any module.WebAppis the only project that references all three modules'ApiandInfrastructure.
One caveat at the Domain level only: to keep view-rendering parity from phase 2 (Trip.CreatedBy.Email, Expense.PaidByUser.FirstName, etc.), the entity classes still declare cross-module navigation properties — annotated [NotMapped] so EF never crosses Postgres schemas. The CrossModuleNavigationTests invariant rejects any mapped cross-module nav. Application/Infrastructure/Api remain isolated; only entity types are shared at the Domain level.
See explanation.md (Estonian, full walkthrough), arhitektuur.md (Estonian, diagrams + reference rules), SplitApp.Modular/docs/ARCHITECTURE.md (English, deep-dive).
Solution layout
SplitApp.Modular/
├── SplitApp.sln
├── Directory.Build.props
├── src/
│ ├── SplitApp.WebApp/ ← composition root, host
│ │ ├── Program.cs ← AddXxxModule(...) wiring
│ │ ├── Application/ ← lifted phase-2 BLL
│ │ │ ├── Services/ (+ Admin/, Identity/)
│ │ │ ├── DTO/
│ │ │ ├── Mappers/
│ │ │ ├── Persistence/AppUnitOfWork.cs ← aggregates 3 module DbContexts
│ │ │ └── Persistence/CrossModuleNavigationLoader.cs
│ │ ├── Areas/Admin/
│ │ ├── Areas/Identity/
│ │ ├── Controllers/
│ │ ├── Views/
│ │ └── Resources/ ← i18n .resx (EN + ET)
│ ├── Shared/
│ │ ├── SplitApp.Shared.Kernel/ ← BaseEntity, IBaseRepo, IUoW, LangStr, IdentityHelpers
│ │ └── SplitApp.Shared.Contracts/ ← MediatR IRequest / INotification
│ └── Modules/
│ ├── Users/ ← AppUser, AppRole, AppRefreshToken; JWT
│ ├── Trips/ ← Trip, Participant, Invitation, Poll, Wishlist, BudgetCategory
│ └── Expenses/ ← Expense, ExpenseSplit, SettlementPlan/Payment, Currency, SplitPreset
└── tests/
├── SplitApp.Modules.Users.Tests/
├── SplitApp.Modules.Trips.Tests/
├── SplitApp.Modules.Expenses.Tests/
└── SplitApp.WebApp.IntegrationTests/ ← architecture invariants + smoke
Each module = mini-Clean-Architecture (Domain ← Application ← Infrastructure, Api for REST). Each module owns its DbContext scoped to its own Postgres schema.
URL map
| URL | Purpose |
|---|---|
/ |
Landing page (MVC) |
/Trips, /Trips/{Create,Details/{id},Edit/{id},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} (CreateCategory/EditCategory/DeleteCategory) |
Budget categories |
/Settlement?tripId={id} |
Balances + settlement plans |
/PollsClient?tripId={id} (Create/Details) |
Trip polls |
/WishlistClient?tripId={id} |
Trip wishlist |
/Identity/Account/Register |
Cookie register |
/Admin/Dashboard |
Admin home (admin role) |
/Admin/{Users, Trips, Expenses, BudgetCategories, Currencies, Invitations, Polls, SettlementPlans, SettlementPayments, SplitPresets, TripParticipants, Wishlist} |
Admin CRUD per 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 |
All API endpoints require JWT bearer auth except account/register and account/login.
Inter-module communication
All cross-module calls go through MediatR. Contracts in Shared.Contracts/<Module>/{Queries|Events|Commands}/; handlers in the owning module.
| Contract | Owner | Purpose |
|---|---|---|
GetUserByIdQuery → UserDto? |
Users | Display name lookup |
GetUsersByIdsQuery → IReadOnlyList<UserDto> |
Users | Batch lookup |
UserDeletedEvent |
Users | Trips + Expenses subscribe to clean up rows |
GetTripByIdQuery → TripSummaryDto? |
Trips | Cross-module trip lookup |
GetTripParticipantsQuery → IReadOnlyList<TripParticipantDto> |
Trips | |
IsTripParticipantQuery → bool |
Trips | IDOR guard in ExpensesController |
TripDeletedEvent |
Trips | Expenses subscribes to delete dependent expenses/settlements |
GetTripExpenseTotalsQuery → TripExpenseTotalsDto |
Expenses | |
GetBudgetCategorySpentQuery → IReadOnlyDictionary<Guid, decimal> |
Expenses | Per-budget-category spent totals |
ExpenseSettledEvent |
Expenses | Reserved |
SettlementPlanCompletedEvent |
Expenses | Trips advances "Finalizing" trips → "Settled" once every payment is confirmed |
Tests
cd SplitApp.Modular
dotnet test
25 tests across four projects:
- Per-module unit tests —
CurrencyConverter(5),LangStr(7),IdentityHelpersJWT round-trips (4) - Architecture invariants —
ModuleBoundaryTests,DbContextSchemaIsolationTests,CrossModuleNavigationTests - Smoke —
WebApplicationFactory<Program>boots the host, hits/,/Home/Index, expects 401 on unauthenticated API
A failing architecture test = someone violated the modular monolith invariant.
Phase 3 ↔ Phase 2 mapping
Phase 3 lifts most of phase 2 unchanged:
| Phase 2 | Phase 3 destination |
|---|---|
Base.Domain, Base.Contracts |
Shared.Kernel |
Base.Helpers (IdentityHelpers) |
Shared.Kernel.Auth |
App.Domain.Identity.* |
Modules/Users/Domain/Entities/ |
App.Domain.{Trip, TripParticipant, ...} |
Modules/Trips/Domain/Entities/ |
App.Domain.{Expense, SettlementPlan, ..., Currency} |
Modules/Expenses/Domain/Entities/ |
App.DAL.EF.AppDbContext |
split into 3 per-module DbContexts |
App.BLL.Services.Identity.* |
Modules/Users/Application/Services/ |
App.BLL.Services.* (Trip, Expense, Settlement, ...) |
WebApp/Application/Services/ (composition-root facade over 3 module UoWs) |
App.BLL.{DTO, Mappers} |
WebApp/Application/{DTO, Mappers} |
WebApp.ApiControllers.{Identity, Trips, Expenses, ...} |
split per module → Modules/X/Api/Controllers/ |
WebApp/{Controllers, Areas/Admin, Areas/Identity, Views} |
preserved structurally; namespace re-rooted to SplitApp.WebApp.* |
The phase-2 BLL is "lifted" into WebApp/Application/ rather than rewritten — this keeps the full UX surface (101 Razor views, 21 MVC + Admin controllers, 10 REST controllers, Identity Razor pages) byte-identical to phase 2 while the inter-module boundaries are enforced cleanly via MediatR + per-module DbContexts.
Files & docs
- explanation.md — Phase 3 walkthrough (Estonian)
- arhitektuur.md — Phase 3 architecture diagrams + rules (Estonian)
- architecture.md — Phase 3 architecture deep-dive (English)
- modularmonolith.md — Course material on the modular monolith pattern
- phase3.md — The original assignment text
- SplitApp.Modular/README.md — Module-level README
- SplitApp.Modular/docs/ARCHITECTURE.md — Per-module architecture details