README.md
11,880 bytes
| 1 | # SplitApp — Trip Expense Management (Phase 3 — Modular Monolith) |
|---|---|
| 2 | |
| 3 | URL: https://travel.rasmusj.com/ |
| 4 | |
| 5 | 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. |
| 6 | |
| 7 | This repo is the **Phase 3 — Modular Monolith** refactor of the project. The whole product lives under [`SplitApp.Modular/`](SplitApp.Modular/): one deployable, three internally isolated modules (**Users**, **Trips**, **Expenses**), MediatR for cross-module communication, schema-per-module Postgres isolation. |
| 8 | |
| 9 | Built for the TalTech "Web Applications with C#" course **Personal Project — Phase 3**. |
| 10 | |
| 11 | --- |
| 12 | |
| 13 | ## Run |
| 14 | |
| 15 | ```bash |
| 16 | docker compose up --build |
| 17 | ``` |
| 18 | |
| 19 | Brings up two containers: |
| 20 | |
| 21 | | Service | Container | Port | Notes | |
| 22 | |---|---|---|---| |
| 23 | | `phase3` | `phase3` | http://localhost:90 | The web app | |
| 24 | | `db` | `phase3-db` | (internal only) | PostgreSQL 16, schemas `users` / `trips` / `expenses` — not exposed to host | |
| 25 | |
| 26 | Module migrations run automatically on host startup. Sample data is seeded if `DataInitialization:SeedData=true` (set in `appsettings.json`). |
| 27 | |
| 28 | Test login (after seed): |
| 29 | - `admin@taltech.ee` / `Foo.Bar.1` — `admin` role, full Admin area access |
| 30 | - `alice@taltech.ee`, `bob@taltech.ee`, `charlie@taltech.ee`, `diana@taltech.ee` / `Foo.Bar.1` — regular users with sample trips |
| 31 | |
| 32 | Stop: |
| 33 | |
| 34 | ```bash |
| 35 | docker compose down |
| 36 | ``` |
| 37 | |
| 38 | --- |
| 39 | |
| 40 | ## Architecture at a glance |
| 41 | |
| 42 | ``` |
| 43 | ┌────────────────────────────────────────────────┐ |
| 44 | │ SplitApp.WebApp (host) │ |
| 45 | │ Program.cs · Controllers · Areas/Admin │ |
| 46 | │ Application/{Services, DTO, Mappers, │ |
| 47 | │ Persistence} │ |
| 48 | └────────────────────────────────────────────────┘ |
| 49 | │ │ │ |
| 50 | ▼ ▼ ▼ |
| 51 | ┌──────────┐ ┌──────────┐ ┌──────────┐ |
| 52 | │ Users │ │ Trips │ │ Expenses │ |
| 53 | │ Domain │ │ Domain │ │ Domain │ |
| 54 | │ App │ │ App │ │ App │ |
| 55 | │ Infra │ │ Infra │ │ Infra │ |
| 56 | │ Api │ │ Api │ │ Api │ |
| 57 | │ schema: │ │ schema: │ │ schema: │ |
| 58 | │ users │ │ trips │ │ expenses │ |
| 59 | └──────────┘ └──────────┘ └──────────┘ |
| 60 | ▲ ▲ ▲ |
| 61 | └─MediatR───┴─MediatR───┘ |
| 62 | ┌──────────────────────┐ ┌──────────────────────┐ |
| 63 | │ Shared.Contracts │ │ Shared.Kernel │ |
| 64 | │ IRequest / INotification│ │ BaseEntity, LangStr │ |
| 65 | └──────────────────────┘ └──────────────────────┘ |
| 66 | ``` |
| 67 | |
| 68 | **Reference rules** (compiler-enforced + verified by `tests/SplitApp.WebApp.IntegrationTests/Architecture/`): |
| 69 | |
| 70 | - A module's `Application` / `Infrastructure` / `Api` can reference: same-module projects + `Shared.Kernel` + `Shared.Contracts`. Nothing else. |
| 71 | - Inter-module function calls go through **MediatR only**. |
| 72 | - `Shared.*` may not reference any module. |
| 73 | - `WebApp` is the only project that references all three modules' `Api` and `Infrastructure`. |
| 74 | |
| 75 | **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. |
| 76 | |
| 77 | See [explanation.md](explanation.md) (Estonian, full walkthrough), [arhitektuur.md](arhitektuur.md) (Estonian, diagrams + reference rules), [SplitApp.Modular/docs/ARCHITECTURE.md](SplitApp.Modular/docs/ARCHITECTURE.md) (English, deep-dive). |
| 78 | |
| 79 | --- |
| 80 | |
| 81 | ## Solution layout |
| 82 | |
| 83 | ``` |
| 84 | SplitApp.Modular/ |
| 85 | ├── SplitApp.sln |
| 86 | ├── Directory.Build.props |
| 87 | ├── src/ |
| 88 | │ ├── SplitApp.WebApp/ ← composition root, host |
| 89 | │ │ ├── Program.cs ← AddXxxModule(...) wiring |
| 90 | │ │ ├── Application/ ← lifted phase-2 BLL |
| 91 | │ │ │ ├── Services/ (+ Admin/, Identity/) |
| 92 | │ │ │ ├── DTO/ |
| 93 | │ │ │ ├── Mappers/ |
| 94 | │ │ │ ├── Persistence/AppUnitOfWork.cs ← aggregates 3 module DbContexts |
| 95 | │ │ │ └── Persistence/CrossModuleNavigationLoader.cs |
| 96 | │ │ ├── Areas/Admin/ |
| 97 | │ │ ├── Areas/Identity/ |
| 98 | │ │ ├── Controllers/ |
| 99 | │ │ ├── Views/ |
| 100 | │ │ └── Resources/ ← i18n .resx (EN + ET) |
| 101 | │ ├── Shared/ |
| 102 | │ │ ├── SplitApp.Shared.Kernel/ ← BaseEntity, IBaseRepo, IUoW, LangStr, IdentityHelpers |
| 103 | │ │ └── SplitApp.Shared.Contracts/ ← MediatR IRequest / INotification |
| 104 | │ └── Modules/ |
| 105 | │ ├── Users/ ← AppUser, AppRole, AppRefreshToken; JWT |
| 106 | │ ├── Trips/ ← Trip, Participant, Invitation, Poll, Wishlist, BudgetCategory |
| 107 | │ └── Expenses/ ← Expense, ExpenseSplit, SettlementPlan/Payment, Currency, SplitPreset |
| 108 | └── tests/ |
| 109 | ├── SplitApp.Modules.Users.Tests/ |
| 110 | ├── SplitApp.Modules.Trips.Tests/ |
| 111 | ├── SplitApp.Modules.Expenses.Tests/ |
| 112 | └── SplitApp.WebApp.IntegrationTests/ ← architecture invariants + smoke |
| 113 | ``` |
| 114 | |
| 115 | Each module = mini-Clean-Architecture (`Domain` ← `Application` ← `Infrastructure`, `Api` for REST). Each module owns its `DbContext` scoped to its own Postgres schema. |
| 116 | |
| 117 | --- |
| 118 | |
| 119 | ## URL map |
| 120 | |
| 121 | | URL | Purpose | |
| 122 | |---|---| |
| 123 | | `/` | Landing page (MVC) | |
| 124 | | `/Trips`, `/Trips/{Create,Details/{id},Edit/{id},Delete/{id}}` | Trip CRUD | |
| 125 | | `/Members?tripId={id}` and `/Members/AcceptInvitation/{token}` | Trip participants + invitation flow | |
| 126 | | `/Expenses?tripId={id}` (with Create/Edit/Delete) | Trip expenses | |
| 127 | | `/Budget?tripId={id}` (CreateCategory/EditCategory/DeleteCategory) | Budget categories | |
| 128 | | `/Settlement?tripId={id}` | Balances + settlement plans | |
| 129 | | `/PollsClient?tripId={id}` (Create/Details) | Trip polls | |
| 130 | | `/WishlistClient?tripId={id}` | Trip wishlist | |
| 131 | | `/Identity/Account/Register` | Cookie register | |
| 132 | | `/Admin/Dashboard` | Admin home (`admin` role) | |
| 133 | | `/Admin/{Users, Trips, Expenses, BudgetCategories, Currencies, Invitations, Polls, SettlementPlans, SettlementPayments, SplitPresets, TripParticipants, Wishlist}` | Admin CRUD per entity | |
| 134 | | `/swagger` | Swagger UI listing every module's REST endpoints | |
| 135 | |
| 136 | ### REST API |
| 137 | |
| 138 | | Module | Endpoints | |
| 139 | |---|---| |
| 140 | | Users | `/api/v1/identity/account/{register, login, logout, refreshtokendata}` | |
| 141 | | Trips | `/api/v1/trips`, `/api/v1/budgetcategories`, `/api/v1/invitations`, `/api/v1/polls`, `/api/v1/wishlist` | |
| 142 | | Expenses | `/api/v1/expenses`, `/api/v1/currencies`, `/api/v1/settlements`, `/api/v1/splitpresets` | |
| 143 | |
| 144 | All API endpoints require JWT bearer auth except `account/register` and `account/login`. |
| 145 | |
| 146 | --- |
| 147 | |
| 148 | ## Inter-module communication |
| 149 | |
| 150 | All cross-module calls go through MediatR. Contracts in `Shared.Contracts/<Module>/{Queries|Events|Commands}/`; handlers in the **owning** module. |
| 151 | |
| 152 | | Contract | Owner | Purpose | |
| 153 | |---|---|---| |
| 154 | | `GetUserByIdQuery → UserDto?` | Users | Display name lookup | |
| 155 | | `GetUsersByIdsQuery → IReadOnlyList<UserDto>` | Users | Batch lookup | |
| 156 | | `UserDeletedEvent` | Users | Trips + Expenses subscribe to clean up rows | |
| 157 | | `GetTripByIdQuery → TripSummaryDto?` | Trips | Cross-module trip lookup | |
| 158 | | `GetTripParticipantsQuery → IReadOnlyList<TripParticipantDto>` | Trips | | |
| 159 | | `IsTripParticipantQuery → bool` | Trips | **IDOR guard** in `ExpensesController` | |
| 160 | | `TripDeletedEvent` | Trips | Expenses subscribes to delete dependent expenses/settlements | |
| 161 | | `GetTripExpenseTotalsQuery → TripExpenseTotalsDto` | Expenses | | |
| 162 | | `GetBudgetCategorySpentQuery → IReadOnlyDictionary<Guid, decimal>` | Expenses | Per-budget-category spent totals | |
| 163 | | `ExpenseSettledEvent` | Expenses | Reserved | |
| 164 | | `SettlementPlanCompletedEvent` | Expenses | Trips advances "Finalizing" trips → "Settled" once every payment is confirmed | |
| 165 | |
| 166 | --- |
| 167 | |
| 168 | ## Tests |
| 169 | |
| 170 | ```bash |
| 171 | cd SplitApp.Modular |
| 172 | dotnet test |
| 173 | ``` |
| 174 | |
| 175 | **25 tests** across four projects: |
| 176 | |
| 177 | - Per-module unit tests — `CurrencyConverter` (5), `LangStr` (7), `IdentityHelpers` JWT round-trips (4) |
| 178 | - Architecture invariants — `ModuleBoundaryTests`, `DbContextSchemaIsolationTests`, `CrossModuleNavigationTests` |
| 179 | - Smoke — `WebApplicationFactory<Program>` boots the host, hits `/`, `/Home/Index`, expects 401 on unauthenticated API |
| 180 | |
| 181 | A failing architecture test = someone violated the modular monolith invariant. |
| 182 | |
| 183 | --- |
| 184 | |
| 185 | ## Phase 3 ↔ Phase 2 mapping |
| 186 | |
| 187 | Phase 3 lifts most of phase 2 unchanged: |
| 188 | |
| 189 | | Phase 2 | Phase 3 destination | |
| 190 | |---|---| |
| 191 | | `Base.Domain`, `Base.Contracts` | `Shared.Kernel` | |
| 192 | | `Base.Helpers` (IdentityHelpers) | `Shared.Kernel.Auth` | |
| 193 | | `App.Domain.Identity.*` | `Modules/Users/Domain/Entities/` | |
| 194 | | `App.Domain.{Trip, TripParticipant, ...}` | `Modules/Trips/Domain/Entities/` | |
| 195 | | `App.Domain.{Expense, SettlementPlan, ..., Currency}` | `Modules/Expenses/Domain/Entities/` | |
| 196 | | `App.DAL.EF.AppDbContext` | split into 3 per-module `DbContext`s | |
| 197 | | `App.BLL.Services.Identity.*` | `Modules/Users/Application/Services/` | |
| 198 | | `App.BLL.Services.*` (Trip, Expense, Settlement, ...) | `WebApp/Application/Services/` (composition-root facade over 3 module UoWs) | |
| 199 | | `App.BLL.{DTO, Mappers}` | `WebApp/Application/{DTO, Mappers}` | |
| 200 | | `WebApp.ApiControllers.{Identity, Trips, Expenses, ...}` | split per module → `Modules/X/Api/Controllers/` | |
| 201 | | `WebApp/{Controllers, Areas/Admin, Areas/Identity, Views}` | preserved structurally; namespace re-rooted to `SplitApp.WebApp.*` | |
| 202 | |
| 203 | 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. |
| 204 | |
| 205 | --- |
| 206 | |
| 207 | ## Files & docs |
| 208 | |
| 209 | - [explanation.md](explanation.md) — Phase 3 walkthrough (Estonian) |
| 210 | - [arhitektuur.md](arhitektuur.md) — Phase 3 architecture diagrams + rules (Estonian) |
| 211 | - [architecture.md](architecture.md) — Phase 3 architecture deep-dive (English) |
| 212 | - [modularmonolith.md](modularmonolith.md) — Course material on the modular monolith pattern |
| 213 | - [phase3.md](phase3.md) — The original assignment text |
| 214 | - [SplitApp.Modular/README.md](SplitApp.Modular/README.md) — Module-level README |
| 215 | - [SplitApp.Modular/docs/ARCHITECTURE.md](SplitApp.Modular/docs/ARCHITECTURE.md) — Per-module architecture details |
| 216 | |