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 —
IsTripParticipantQueryMediatR-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/Applicationei viitaModules/Trips/*-le egaModules/Expenses/*-le- Kui
Tripsvajab kasutaja-nime, saadab taGetUserByIdQueryMediatR-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,CrossModuleNavigationTestsWebApp.IntegrationTests/HostBootSmokeTests+HostFeatureTests—WebApplicationFactory<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):
ExpensesControllersaadabIsTripParticipantQuery(tripId, userId)→ MediatR- Trips mooduli
IsTripParticipantHandlerkontrollibTripsDbContext-st — tagastabbool - Kui
false→ 403 Forbidden (IDOR-i kaitse) - Kui
true→ expense salvestubExpensesDbContext-i (schemaexpenses)
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:
- WebApp facade
AppUnitOfWork-is — repod hüdreerivad cross-module navsid C#-is pärast põhipäringut (vtCrossModuleHydration.HydrateUsersAsync,HydrateTripsAsync) - Või MediatR-i kaudu —
ExpensesControllerküsib Users-mooduli käestGetUsersByIdsQuery-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:
phase3→ http://localhost:90 (host port90→ container port8080)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/:
ModuleBoundaryTests— ükski mooduliApplication/Infrastructure/Api<ProjectReference>ei viita teisele moodulile;Shared.*ei viita ühelegi moodulile.DbContextSchemaIsolationTests— igaDbContextsisaldabDbSet<T>-e ainult oma mooduliDomainprojektist.CrossModuleNavigationTests— cross-module nav-property on lubatud ainult[NotMapped]-iga.HostBootSmokeTests—WebApplicationFactory<Program>käivitab täis-host'iTestingkeskkonnas (skiipib migratsioonid),/,/Home/Indexja autentimata API-päring tagastab 401.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=etei 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.