profileShare

rasmusjy / splitapp-backend-modular-monolith

Read-only snapshot

No repository description.

main default branch 418 files Expires Sep 13, 2026, 9:06 AM
README.md 11,880 bytes

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.1admin role, full Admin area access
  • alice@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 / Api can reference: same-module projects + Shared.Kernel + Shared.Contracts. Nothing else.
  • Inter-module function calls go through MediatR only.
  • Shared.* may not reference any module.
  • WebApp is the only project that references all three modules' Api and Infrastructure.

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 (DomainApplicationInfrastructure, 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), IdentityHelpers JWT 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