profileShare

rasmusjy / splitapp-backend-modular-monolith

Read-only snapshot

No repository description.

main default branch 418 files Expires Sep 13, 2026, 9:06 AM
explanation.md 20,059 bytes

SplitApp — Reisikulude haldamise rakendus (Phase 3 — Modulaarne Monoliit)

Ülevaade

SplitApp on ASP.NET Core 10.0 veebirakendus grupireisi kulude jagamiseks ja haldamiseks. Kasutajad loovad reise, kutsuvad sõpru, lisavad kulusid paindliku jagamisega, haldavad eelarvet, peavad küsitlusi, soovinimekirju ja arveldavad võlgu optimeeritud algoritmiga.

Phase 3 refaktorib Phase 2 Clean/Onion monoliidi modulaarseks monoliidiks: üks deployable, kolm sisemiselt isoleeritud moodulit (Users, Trips, Expenses), MediatR moodulite vaheliseks suhtluseks, schema-per-moodul Postgres-i isolatsioon.

Projekt on tehtud TalTech kursuse "Web Applications with C#" Personal Project — Phase 3 raames.


0. Phase 3 nõuete täitmine

phase3.md ütleb:

Implement your project in aspnet.core in modular monolith architecture (make copy of phase2, new repo). Split out into at least 3 modules (users, 2 of your own). Use mediator for communication between modules. No direct references between modules.

# Nõue Staatus Asukoht
1 ASP.NET Core modulaarne monoliit Kogu SplitApp.Modular/ — üks WebApp host, 3 moodulit
2 Vähemalt 3 moodulit (users + 2 omad) Users, Trips, Expenses SplitApp.Modular/src/Modules/
3 MediatR moodulite-vaheliseks suhtluseks 11 lepingut Shared.Contracts/GetUserByIdQuery, TripDeletedEvent, IsTripParticipantQuery, SettlementPlanCompletedEvent jne
4 Mitte mingeid otseseid viiteid moodulite vahel ⚠️ Application/Infrastructure/Api kihil ✅, Domain kihil kõrvalekalle Vaata §3

Phase 2 nõuded on kõik säilitatud Phase 3-s:

  • 19 entiteeti (3 Users + 9 Trips + 7 Expenses)
  • REST API + versioneerimine + Swagger
  • JWT auth (Shared.Kernel.Auth.IdentityHelpers)
  • Klient-MVC + Admin Area + Identity Razor Pages
  • i18n UI (resx) + i18n DB (LangStr)
  • IDOR (kasutaja näeb ainult oma andmeid REST-is — IsTripParticipantQuery MediatR-i kaudu)
  • CI/CD deploy (Dockerfile + docker-compose)
  • Repositories + UoW + Services + BLL + Mappers (lifted phase 2 BLL WebApp/Application/-i)

1. Arhitektuur — Modulaarne Monoliit

              ┌────────────────────────────────────────────────────┐
              │           SplitApp.WebApp (Composition Root)       │
              │   Program.cs · Controllers · Areas/Admin · Views   │
              │   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  │
                └──────────────────────┘ └──────────────────────┘

Põhimõte: üks deployable, kolm isoleeritud moodulit. Iga moodul = mini-Clean-Architecture (Domain/Application/Infrastructure/Api). Cross-module kõned eranditult MediatR-iga.

Kursuse loengu võtmelaused (modular monolith — modularmonolith.md)

"Modules never reference each other's internals. Module A doesn't touch Module B's entities, repositories, or DbContext."

"Communication goes through: contracts (interfaces in shared project) and domain events (in-process, loose coupling)."

"Each module has its own DbContext scoped to its tables — modules don't share database contexts."

Meie projekt järgib seda:

  • Modules/Users/Application ei viita Modules/Trips/*-le ega Modules/Expenses/*-le
  • Kui Trips vajab kasutaja-nime, saadab ta GetUserByIdQuery MediatR-i kaudu — Users module's handler vastab
  • Iga moodul omab oma DbContext-i ja Postgres schema (cross-module SQL JOIN-id keelatud)

2. Lahenduse struktuur

SplitApp.Modular/
├── SplitApp.sln
├── Directory.Build.props
└── src/
    ├── SplitApp.WebApp/                                 ← composition root, host
    │   ├── Program.cs                                   ← DI wiring, AddXxxModule(...)
    │   ├── Application/                                 ← Phase 2 BLL liigutatud
    │   │   ├── Services/  (+ Admin/, Identity/)         ← TripService, ExpenseService, ...
    │   │   ├── DTO/                                     ← TripBllDto, ExpenseBllDto, ...
    │   │   ├── Mappers/                                 ← Domain↔BllDto factory mapperid
    │   │   ├── Persistence/AppUnitOfWork.cs             ← agregeerib 3 mooduli DbContext-id
    │   │   ├── Persistence/CrossModuleNavigationLoader.cs ← hüdreerib [NotMapped] cross-navsid
    │   │   └── Contracts/IAppUnitOfWork.cs              ← Phase 2 stiilis facade
    │   ├── Areas/Admin/    ← admin UX
    │   ├── Areas/Identity/ ← Razor Register
    │   ├── Controllers/    ← klient MVC
    │   ├── Views/          ← klient vaated
    │   └── Resources/      ← i18n .resx
    ├── Shared/
    │   ├── SplitApp.Shared.Kernel/                      ← BaseEntity, IBaseRepo, IUoW, LangStr, IdentityHelpers
    │   └── SplitApp.Shared.Contracts/                   ← MediatR contracts
    └── Modules/
        ├── Users/          ← AppUser, AppRole, AppRefreshToken; JWT issuance
        ├── Trips/          ← Trip, TripParticipant, TripPoll, TripWishlistItem, BudgetCategory, ...
        └── Expenses/       ← Expense, ExpenseSplit, SettlementPlan, SettlementPayment, Currency, SplitPreset, ...

tests/ sisaldab:

  • 3 mooduli unit-teste (CurrencyConverter, LangStr, IdentityHelpers)
  • WebApp.IntegrationTests/Architecture/ModuleBoundaryTests, DbContextSchemaIsolationTests, CrossModuleNavigationTests
  • WebApp.IntegrationTests/HostBootSmokeTests + HostFeatureTestsWebApplicationFactory<Program> HTTP-smoke + ristlõikeliste nõuete testid

Kokku 44 testi — kõik green.


3. Mooduli piirid — viidete reeglid

Compiler-enforced + verifitseeritud architecture-testidega.

Allikas Lubatud sihtmärgid Märkus
Modules/X/Application sama mooduli Domain + Shared.Kernel + Shared.Contracts
Modules/X/Infrastructure sama mooduli Domain + Application + Shared.Kernel
Modules/X/Api sama mooduli Application + Shared.Kernel + Shared.Contracts
Shared.* mitte ühelegi moodulile
WebApp kõik 3 mooduli Api + Infrastructure + Shared.* composition root

Kõrvalekalle Domain tasandil: entiteedi-klassidel on cross-module nav-property'd (Trip.CreatedBy, Expense.PaidByUser, Trip.DefaultCurrency jms), kõik [NotMapped]-iga märgitud. Et tüübid (AppUser, Currency, Expense) kompileeruksid, on Domain-csproj-idel viited:

Modules/Trips/Domain.csproj    →  Modules/Users/Domain + Modules/Expenses/Domain
Modules/Expenses/Domain.csproj →  Modules/Users/Domain

Miks see olemas on? Iga moodul käib ainult oma DbContext-i kaudu — ehk ExpensesDbContext ei tea midagi users skeemist. Aga UI peab näitama "kulu 80€ — maksis Alice Johnson". WebApp-i fassaad (AppUnitOfWork) lahendab selle nii: tõmbab kõigepealt Expense-id Expenses-DB-st, siis päring Users-DB-st õigete AppUser-ite järgi, ja käsitsi C#-koodis paneb expense.PaidByUser = user. Et see omistamine oleks tüübikindel (kompilaator kontrollib, IDE pakub autocomplete'i, vead leitakse build-i ajal — mitte runtime'is), peab Expense-klass teadma AppUser tüüpi → siit Domain-csproj-viide.

[NotMapped] tagab samal ajal, et andmebaas jääb sellest täiesti puutumata — EF ei tee veergu, ei tee JOIN-i, ei näe seda välja. Schema-isolatsioon säilib täielikult.

Kompromiss: Application/Infrastructure/Api jäävad puhtaks ja kasutavad MediatR-i. Schema-isolatsioon + suhtluse-isolatsioon runtime-tasemel on 100% säilitatud. Ainult entiteedi-tüübid on jagatud — see on C# tüübisüsteemi mugavus mälus-ühendamise jaoks, mitte funktsionaalne sõltuvus.

CrossModuleNavigationTests kindlustab: kui keegi proovib teha mapped (mitte-[NotMapped]) cross-module nav-i, siis test kukub.


4. Moodulite-vaheline suhtlus (MediatR)

Lepingud elavad Shared.Contracts/<Moodul>/{Queries|Events|Commands}/. Iga leping on record mis implementeerib kas:

  • IRequest<T> — sünkroonne päring/käsk (üks vastus)
  • INotification — fan-out sündmus (mitu tellijat)

Saadetakse hetkel 11 lepingut:

Leping Omanik Otstarve
GetUserByIdQuery → UserDto? Users Trips/Expenses kasutavad nime kuvamiseks
GetUsersByIdsQuery → IReadOnlyList<UserDto> Users Partii-päring
UserDeletedEvent (notification) Users Trips + Expenses tellivad — eemaldavad seotud kirjed
GetTripByIdQuery → TripSummaryDto? Trips Cross-module reisi-otsing
GetTripParticipantsQuery → IReadOnlyList<TripParticipantDto> Trips
IsTripParticipantQuery → bool Trips IDOR-i kaitse: ExpensesController kasutab seda enne expense-i salvestamist
TripDeletedEvent (notification) Trips Expenses tellib — kustutab kulud + arveldused
GetTripExpenseTotalsQuery → TripExpenseTotalsDto Expenses
GetBudgetCategorySpentQuery → IReadOnlyDictionary<Guid, decimal> Expenses Eelarve-kategooria kulutused
ExpenseSettledEvent (notification) Expenses Reserveeritud tulevikuks
SettlementPlanCompletedEvent (notification) Expenses Trips tellib — kui kõik maksed kinnitatud, märgib reisi "Settled"

Näide voost: kasutaja loob expense-i (POST /api/v1/expenses):

  1. ExpensesController saadab IsTripParticipantQuery(tripId, userId) → MediatR
  2. Trips mooduli IsTripParticipantHandler kontrollib TripsDbContext-st — tagastab bool
  3. Kui false → 403 Forbidden (IDOR-i kaitse)
  4. Kui true → expense salvestub ExpensesDbContext-i (schema expenses)

5. Andmeisolatsioon

Üks Postgres andmebaas, kolm schemat. Kolm DbContext-i ühenduvad sama ConnectionStrings:DefaultConnection-i kaudu, aga igaüks kasutab b.HasDefaultSchema("...")-d:

DbContext Schema Sisu
UsersDbContext : IdentityDbContext<AppUser, AppRole, Guid> users AspNetUsers, AspNetRoles, RefreshTokens, DataProtectionKeys
TripsDbContext : DbContext trips Trips, TripParticipants, TripInvitations, TripPolls, TripWishlistItems, BudgetCategories
ExpensesDbContext : DbContext expenses Expenses, ExpenseSplits, SettlementPlans, SettlementPayments, Currencies, SplitPresets

Cross-module SQL JOIN-id on keelatud. EF kunagi ei lähe schema piirist üle, sest cross-module nav-property'd on [NotMapped]. Cross-module andmete komponeerimine toimub:

  1. WebApp facade AppUnitOfWork-is — repod hüdreerivad cross-module navsid C#-is pärast põhipäringut (vt CrossModuleHydration.HydrateUsersAsync, HydrateTripsAsync)
  2. Või MediatR-i kaudu — ExpensesController küsib Users-mooduli käest GetUsersByIdsQuery-iga

Andmete terviklikkus (cross-module FK puudub) tagatakse:

  • Eel-MediatR valideerimispäringutega (IsTripParticipantQuery)
  • Domeeni-sündmustega kustutamisel (UserDeletedEvent, TripDeletedEvent)

6. Phase 2 BLL "lifted" struktuur WebApp-is

Phase 3 ei loo uut BLL-i; selle asemel liigutab Phase 2 BLL koodi WebApp/Application/ alla. See on praktiline kompromiss, mis hoiab Phase 2 100% paarsust UX-iga ja säästab refaktori-aega.

Phase 2 projekt Phase 3 sihtmärk
App.BLL/Services/* (Trip, Expense, Settlement, ...) WebApp/Application/Services/*
App.BLL/Services/Admin/* (12 admin-teenust) WebApp/Application/Services/Admin/*
App.BLL/Services/Identity/* Modules/Users/Application/Services/ (siiski liigutatud Users-moodulisse)
App.BLL/DTO/* WebApp/Application/DTO/*
App.BLL/Mappers/*BllDtoFactory.cs WebApp/Application/Mappers/*
App.Domain/Contracts/IAppUnitOfWork.cs WebApp/Application/Contracts/IAppUnitOfWork.cs
App.DAL.EF.AppUnitOfWork WebApp/Application/Persistence/AppUnitOfWork.cs (3 DbContext-i agregaator)
App.DAL.EF.Repositories.* inline WebApp/Application/Persistence/AppUnitOfWork.cs (TripRepo, ExpenseRepo jne)

Lifted BLL teenused töötavad endiselt IAppUnitOfWork-i kaudu. Teenuse vaatest pole midagi muutunud — _uow.Trips.GetByIdAsync(...) käitub samamoodi nagu Phase 2-s. Tegelikkuses suunab AppUnitOfWork.Trips päringud TripsDbContext-le, ja CrossModuleHydration täidab cross-module nav-id (Trip.CreatedBy jms) tagantjärele eraldi päringuga.


7. Käivitamine

Tootmine (deployd): https://travel.rasmusj.com/

Lokaalselt repo juurest:

docker compose up --build

Tõuseb üles:

  • phase3http://localhost:90 (host port 90 → container port 8080)
  • phase3-db (PostgreSQL 16) — ainult Docker sisevõrgus, host port pole avatud

Iga mooduli migratsioonid jooksevad automaatselt host-i käivitumisel (vt *ModuleExtensions.UseXxxModule(...)-it).

Testid

cd SplitApp.Modular
dotnet test

Roheliseks läheb 44 testi:

Projekt Testid Mida katavad
SplitApp.Modules.Users.Tests 8 IdentityHelpers — JWT genereerimine + valideerimine + tagasilükkamine vale issuer/audience/võtme/malformed-tokeni puhul
SplitApp.Modules.Trips.Tests 12 LangStr — mitme-keele tõlke teisendus + fallback + tühi/null/error piirjuhud
SplitApp.Modules.Expenses.Tests 10 CurrencyConverter — kursi-teisendused + null/negatiivsed summad + ümardamine + round-trip täpsus
SplitApp.WebApp.IntegrationTests 14 Arhitektuuri invariandid (mooduli piirid, schema-isolatsioon, [NotMapped] reegel) + WebApplicationFactory HTTP-smoke (Home/, Swagger UI + v1 doc, Admin auth-redirect, REST API JWT-nõue, lokaliseerimine ?culture=et)

Unit-testid ei vaja andmebaasi — käivituvad millisekundites ja testivad puhast loogikat (kursi-teisendus, JWT-token, LangStr). Integration-testid käivitavad reaalse WebApp hosti mälus (WebApplicationFactory<Program> + "Testing" keskkond, kus migratsioonid skiipitakse) ja teevad HTTP-päringuid — see kinnitab, et iga ristlõikeline nõue (Swagger, JWT, Admin kaitse, i18n) on päriselt töökorras, mitte ainult konfiguratsioonis olemas.


8. URL kaart

URL Otstarve
/ Avalehe MVC
/Trips, /Trips/Create, /Trips/Details/{id}, ... Reisi CRUD
/Members?tripId={id} ja /Members/AcceptInvitation/{token} Osalejad + kutse
/Expenses?tripId={id} (Create/Edit/Delete) Kulud reisi kohta
/Budget?tripId={id} (CreateCategory/EditCategory/DeleteCategory) Eelarvekategooriad
/Settlement?tripId={id} Bilanss + arveldusplaanid
/PollsClient?tripId={id} (Create/Details) Reisi küsitlused
/WishlistClient?tripId={id} Soovinimekiri
/Identity/Account/Register Cookie-põhine registreerumine
/Admin/Dashboard Admin avaleht (admin roll)
/Admin/{Users, Trips, Expenses, BudgetCategories, Currencies, Invitations, Polls, SettlementPlans, SettlementPayments, SplitPresets, TripParticipants, Wishlist} Admin CRUD
/swagger Swagger UI

REST API

Moodul Endpoint-id
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

9. Architecture-testid (mooduli piiride lukustamine)

tests/SplitApp.WebApp.IntegrationTests/Architecture/:

  1. ModuleBoundaryTests — ükski mooduli Application/Infrastructure/Api <ProjectReference> ei viita teisele moodulile; Shared.* ei viita ühelegi moodulile.
  2. DbContextSchemaIsolationTests — iga DbContext sisaldab DbSet<T>-e ainult oma mooduli Domain projektist.
  3. CrossModuleNavigationTests — cross-module nav-property on lubatud ainult [NotMapped]-iga.
  4. HostBootSmokeTestsWebApplicationFactory<Program> käivitab täis-host'i Testing keskkonnas (skiipib migratsioonid), /, /Home/Index ja autentimata API-päring tagastab 401.
  5. HostFeatureTests — ristlõikeliste nõuete HTTP-tasandil kontroll: Swagger v1 doc + UI serveeritakse, /Admin/* suunab autentimata kasutaja Identity login-lehele, /api/v1/expenses/* nõuab JWT-d (teine moodul, sama reegel), ?culture=et ei riku request-localization ahelat.

Kui mõni neist langeb, on keegi rikkunud modulaarmonoliidi invariandi või ristlõikelise nõude.


10. Miks modulaarne monoliit?

Lähenemine Probleem
Klassikaline monoliit Kõik viitab kõigele — üks muudatus → kaskaad-mõju
Mikroteenused Hajusüsteemide põrgu — võrk, serialiseerimine, eventual consistency, deployment-keerukus
Modulaarne monoliit Selged piirid (nagu mikroteenustel) + lihtne deployment (nagu monoliidil)

Ekstraheerimise tee tulevikus, kui peaks vaja minema:

  • Klassikaline monoliit → Modulaarne monoliit → Mikroteenused
  • Iga moodul juba omab oma schema, oma lepingud, oma DbContext-i. Mooduli eraldamine teenuseks tähendab in-process MediatR-kõnete asendamist HTTP/gRPC-ga ning sündmuste viimist message brokeri peale. Koodi struktuur ei muutu — ainult transport-kiht.

Vaata kursuse modularmonolith.md faili pikemaks aruteluks.


Kokkuvõte

SplitApp Phase 3 on modulaarne monoliit kolme isoleeritud mooduliga (Users, Trips, Expenses). Iga moodul on iseseisev mini-Clean-Architecture oma Domain/Application/Infrastructure/Api projektidega ja oma Postgres schema. Cross-module suhtlus käib ainult MediatR-i kaudu (IRequest/INotification), mitte otseste <ProjectReference>-ite kaudu. Architecture-testid lukustavad need invariandid CI ajal. Kõigil Phase 2 nõuetel (REST API + versioning + Swagger, JWT, MVC + Admin Area, i18n, IDOR, Repos/UoW/Services/BLL/Mappers, CI/CD, testid) on Phase 3-s täielik kate.