Architektura kódu

Vrstvy, hexagonal/clean, DDD-lite, DTO & mapování, DI.

Tahle kapitola není o tom, co tvůj backend dělá, ale jak je poskládaný uvnitř — kam který kus kódu patří. Většina backendů totiž neumře na špatný algoritmus, ale na chaos: pravidla appky rozházená všude možně, jedna malá změna rozbije pět nesouvisejících věcí, a po půl roce se v tom nikdo nevyzná. Dobrá architektura tomu předchází. Vezmeme to od základních pojmů.


Pár pojmů na úvod

  • Byznys logika = pravidla tvojí aplikace, ta „chytrá" část. Např. „uživatel nesmí mít víc než 3 aktivní objednávky" nebo „slevu lze uplatnit jen jednou". To je srdce appky.
  • Controller (route handler) = kód, který přijme příchozí HTTP request a rozhodne, co s ním.
  • Service = kód, kde žije byznys logika.
  • Repository = kód, který se stará o ukládání a čtení z databáze.
  • ORM = knihovna, přes kterou pracuješ s databází pomocí objektů místo psaní SQL.
  • Závislost (dependency) = něco, co jeden kus kódu potřebuje k práci (např. service potřebuje repository, aby se dostal k datům).

Nelekej se, projdeme to teď na jednoduchém příměru.


Vrstvy — jako restaurace

Nejjednodušší způsob, jak backend uspořádat, je rozdělit ho do vrstev, kde každá má jednu práci. Představ si restauraci:

  • Controller = číšník. Převezme od hosta požadavek, zkontroluje, že dává smysl, předá ho do kuchyně a pak donese výsledek. Sám nevaří — žádná byznys logika, žádné SQL.
  • Service = kuchař. Tady se odehrává ta „chytrá" práce podle pravidel (ověř, že má uživatel místo na další objednávku, spočítej cenu se slevou). Neřeší, jak přišel požadavek (HTTP?), ani kde jsou data uložená.
  • Repository = spíž. Umí jen donést a uložit data („dej mi uživatele s ID 5"). Service neřeší, jestli je za tím Postgres, nebo něco jiného.

Pravidlo, které si zapamatuj: tencí číšníci, vytížení kuchaři, hloupá spíž (thin controllers, fat services, dumb repositories). Když začneš vařit u stolu (byznys logika v kontroleru), nejde to pak znovupoužít odjinud (třeba ze zpracování fronty) ani pořádně otestovat.


Separation of Concerns — každý dělá jen svoje

Hlavní myšlenka výše má jméno: separation of concerns (oddělení starostí). Každý kus kódu má řešit jednu věc. Když jedna funkce zároveň parsuje HTTP, počítá cenu, posílá e-mail a píše do databáze, nejde ji bezpečně změnit — sáhneš na jedno a rozbiješ druhé.

Dva pojmy, které k tomu patří:

  • Cohesion (soudržnost) — věci, co spolu souvisí, jsou pohromadě. Chceš ji vysokou.
  • Coupling (provázanost) — jak moc jeden modul závisí na druhém. Chceš ho nízký.

Cíl: vysoká soudržnost, nízká provázanost. Každý modul dělá jednu věc pořádně a o ostatních ví co nejmíň.


Hexagonal architecture — když to chceš zatáhnout dál

Jakmile appka roste, používá se pokročilejší uspořádání (uvidíš pojmy hexagonal architecture, ports & adapters, clean architecture — je to v zásadě totéž). Myšlenka: byznys logika (doména) je uprostřed a neví vůbec nic o vnějším světě. Všechno vnější — HTTP, databáze, fronta, e-mail — jsou adaptéry, které se připojují přes porty (rozhraní = domluvený „zásuvkový" tvar).

Klíčové pravidlo (dependency rule): závislosti směřují dovnitř, k doméně. Doména nezávisí na databázi; je to naopak — databáze se přizpůsobí doméně. Výhoda: vyměníš databázi za jinou, nebo HTTP za zpracování fronty, a jádro appky se nezmění. A jádro se dá otestovat úplně bez databáze.

Ale pozor na opačný extrém. Pro malou appku jsou plné vrstvy a porty zbytečná ceremonie. Začni jednoduše (klidně controller → service → ORM) a přidávej strukturu, až když složitost reálně roste. Architektura ti má sloužit, ne být náboženství.


Kam patří byznys logika

Nejčastější chyba začátečníka: napsat pravidla appky do kontroleru nebo přímo do databázových modelů. Patří do service / doménového modelu. Dva přístupy, jak to uspořádat:

  • Anemic model — entity jsou jen „pytlíky na data" bez chování a všechna logika je v service. Jednoduché a běžné, ale logika se může rozlézt do moc míst.
  • Rich domain model — entita si sama hlídá svoje pravidla (objednavka.zrus() ověří, že se zrušit dá). Logika je u dat, hůř se rozteče. Je to srdce DDD (viz níže).

Ať zvolíš cokoli: byznys logika NIKDY nepatří do kontroleru ani do databázové vrstvy.


DDD-lite — pár užitečných pojmů

DDD (Domain-Driven Design) je celá filozofie návrhu kolem domény. Nemusíš ji dělat celou, ale tyhle pojmy se ti budou hodit:

  • Entity — věc s identitou, která přetrvává změny (uživatel #42 je pořád ten samý, i když změní jméno).
  • Value object — věc definovaná svou hodnotou, neměnná (peníze, adresa). Money(100, 'CZK'); dvě stejné částky jsou zaměnitelné, nemají vlastní identitu.
  • Aggregate — skupina entit, které se mění jako celek přes jeden „hlavní" objekt (objednávku a její položky měníš jen přes Order). Drží data konzistentní.
  • Ubiquitous language — kód mluví jazykem byznysu: metoda se jmenuje rezervujMisto(), ne updateRow(); třída je Faktura, ne DataRow2. Ať kód čte i člověk z byznysu.

DTO a mapování mezi vrstvami

Data nemají mít všude stejný tvar. Jinak vypadá entita v databázi (viz výše) a jinak to, co pošleš klientovi přes API. DTO (Data Transfer Object) je prostý objekt bez logiky, jehož jediný úkol je přenést data přes hranici (typicky mezi API a klientem) ve správném tvaru.

Proč nikdy neposílat klientovi přímo databázovou entitu:

  1. Únik citlivých polí — entita User má i passwordHash, isAdmin. Když ji pošleš celou, vyzradíš je (bezpečnostní díra, viz Security).
  2. Provázanost — API navázané na databázi se rozbije klientům při každé změně schématu (přejmenuješ sloupec → spadne mobilní appka).
  3. Jiný tvar — klient často chce data jinak, než leží v databázi.

Proto se na hranici vytvoří DTO: request DTO (co přijímáš — hned ho zvaliduješ a vezmeš jen povolená pole, aby klient nepodstrčil isAdmin: true) a response DTO (co posíláš — jen pole, co má vidět). Při průchodu aplikací tak data mění tvar a mapper je mezi tvary převádí:

Pozor i tady na opačný extrém: u triviální appky můžou být tři skoro stejné tvary zbytečná byrokracie. Začni jednoduše a odděluj DTO, až ti chybějící hranice začne dělat problémy (únik polí, rozbíjení API).


Dependency Injection — předávej závislosti zvenčí

Když si nějaká třída sama vyrobí to, co potřebuje (new PostgresRepo()), je s tím napevno svázaná. Dependency Injection (DI) znamená, že jí to potřebné předáš zvenčí (typicky v konstruktoru):

// ❌ napevno svázané – v testu to nejde obejít, potřebuješ reálnou databázi
class UserService { repo = new PostgresRepo() }

// ✅ injektované – v testu předáš falešné (fake) repo, žádná databáze
class UserService { constructor(private repo: UserRepository) {} }

Výhody: snáz se testuje (podstrčíš falešnou závislost), snadno vyměníš implementaci a je jasně vidět, na čem třída závisí. Právě DI dělá hexagonální architekturu prakticky použitelnou.


Struktura složek: podle vrstev, nebo podle feature?

Podle vrstev (by layer)Podle feature (doporučeno, jak appka roste)
Složkycontrollers/, services/, repos/users/, orders/, billing/
VýhodaJasně vidět vrstvyVše k jedné funkci pohromadě, snadno smazat/přesunout
NevýhodaJedna funkce roztažená po 5 složkáchVrstvy si musíš hlídat uvnitř každé feature

Malý projekt: „podle vrstev" stačí. Jak roste, „podle feature" se obvykle udržuje líp (souvisí s modulárním monolitem v Tech Choices).


Anti-patterny (čeho se vyvarovat)

  • 🚩 Byznys logika v kontroleru / v databázovém modelu — nejde testovat ani znovupoužít.
  • 🚩 God object — jedna obří třída „UserManager" o 2000 řádcích, co dělá úplně všechno.
  • 🚩 Circular dependencies — A potřebuje B, B potřebuje A. Známka špatně vedených hranic.
  • 🚩 Premature architecture — pět vrstev a tucet rozhraní na appku se třemi endpointy.

Failure modes — jak to dopadne, když architekturu zanedbáš

  • Spaghetti kód → každá změna má nečekané vedlejší efekty, protože všechno souvisí se vším.
  • Big ball of mud → žádné jasné hranice; po roce nikdo neví, kde co je.
  • Over-engineering → tolik abstrakcí, že přidat jeden endpoint trvá celý den.
  • Netestovatelnost → logika přilepená na HTTP a databázi → testy potřebují celý systém.

🛠️ Cvičení

  1. Rozvrstvi chaos. Vezmi (klidně smyšlený) endpoint, který v jednom kuse kódu validuje vstup, počítá cenu, ukládá do databáze a posílá e-mail. Rozděl ho na controller → service → repository.
  2. Injektuj závislost. Přepiš class OrderService { repo = new PostgresRepo() } tak, aby repo dostával v konstruktoru. Pak naskicuj test, kde mu předáš falešné repo bez databáze.
  3. Navrhni DTO. Entita Userid, email, passwordHash, isAdmin, createdAt. Navrhni response DTO pro veřejný profil — co v něm bude a co ne a proč. A co zvaliduješ u request DTO?
  4. Kam patří pravidlo? Pro „uživatel nesmí mít víc než 3 aktivní objednávky" urči, do které vrstvy logika patří a proč ne do kontroleru.
  5. Anemic vs rich. Naskicuj objekt Order dvakrát: jednou anemicky (data + service zvlášť), jednou richly (order.zrus() si hlídá pravidla sám). Pojmenuj výhody a nevýhody.
  6. Podle vrstev vs podle feature. Načrtni stejnou appku (users, orders, billing) v obou strukturách složek a řekni, kdy která začne být nepříjemná.
Náčrt řešení — rozbal, až si cvičení zkusíš sám
  1. Rozvrstvi chaos — controller jen přijme request, zvaliduje vstup a zavolá service; service spočítá cenu a řídí pravidla; repository ukládá; e-mail je další závislost service (přes port). Pointa: „tencí číšníci, vytížení kuchaři" — past je nechat výpočet ceny nebo SQL v kontroleru, pak to nejde znovupoužít z fronty ani otestovat.
  2. Injektuj závislost — z repo = new PostgresRepo() uděláš constructor(private repo: UserRepository); v testu předáš fake repo, které vrací data z paměti, žádná databáze. Na co pozor: typuj parametr na rozhraní (UserRepository), ne na konkrétní PostgresRepo — jinak ti DI nepomůže a fake nepodstrčíš.
  3. Navrhni DTO — response DTO pro veřejný profil: id, email (pokud má být vidět), createdAt; nikdy passwordHash ani isAdmin (únik). U request DTO vezmeš jen povolená pole a zvaliduješ je. Pointa: nikdy nevracej entitu celou ani nezapisuj naslepo, čím klient pošle — jinak si podstrčí isAdmin: true.
  4. Kam patří pravidlo? — „max 3 aktivní objednávky" je byznys logika → patří do service / doménového modelu, ne do kontroleru. Důvod: v kontroleru jde svázané s HTTP, nejde znovupoužít z fronty/cronu a špatně se testuje (test by potřeboval celý HTTP stack).
  5. Anemic vs rich — anemic: Order je pytlík dat, pravidla v OrderService (jednoduché, ale logika se rozleze); rich: order.zrus() si pravidla hlídá sám (logika u dat, hůř se rozteče). Pointa: obojí je legitimní — ať zvolíš cokoli, logika nikdy nesmí skončit v kontroleru ani v databázové vrstvě.
  6. Podle vrstev vs podle feature — „podle vrstev" (controllers/, services/) je jasné u malé appky, ale jedna funkce se roztáhne po 5 složkách; „podle feature" (users/, orders/) drží vše pohromadě a líp škáluje. Na co pozor: u feature struktury si vrstvy musíš hlídat uvnitř každé feature, jinak se chaos jen přesune dovnitř.

🧠 Otázky & odpovědi

Proč byznys logika nepatří do kontroleru?

Kontroler je svázaný s HTTP. Logika v něm nejde znovupoužít z jiného vstupu (zpracování fronty, cron, příkazová řádka), špatně se testuje (test potřebuje celý HTTP stack) a má sklon nabobtnat do obří nepřehledné třídy. Patří do service / doménového modelu, který o HTTP ani o konkrétní databázi nic neví. Kontroler má jen přijmout, zvalidovat, zavolat service a vrátit odpověď.

Co říká dependency rule v hexagonální architektuře?

Závislosti směřují dovnitř, k doméně. Byznys logika nezávisí na ničem vnějším; naopak okolní technologie (databáze, HTTP, fronta) se přizpůsobují doméně tím, že implementují její rozhraní (porty). Důsledek: vyměníš databázi nebo HTTP za zpracování fronty a jádro appky se nezmění — a otestuješ ho bez databáze.

Jak dependency injection zlepšuje testovatelnost?

Když si třída sama vyrábí závislosti (new PostgresRepo()), je s nimi pevně srostlá a v testu je nejde obejít → test potřebuje reálnou databázi. S dependency injection dostane závislost zvenčí, takže v testu jí podstrčíš falešnou (fake) implementaci. Testy jsou tím rychlejší, spolehlivější a je jasně vidět, na čem třída závisí.

Jaký je rozdíl mezi entity, value object a aggregate?

Entity má identitu, která přetrvává změny (uživatel #42 i po přejmenování). Value object je definovaný svou hodnotou a je neměnný (peníze, adresa — dvě stejné jsou zaměnitelné, nemají vlastní identitu). Aggregate je skupina entit, které se mění jako celek přes jeden hlavní objekt (objednávku a její položky měníš jen přes Order), čímž drží data konzistentní.

Co je DTO a proč neposílat klientovi přímo databázovou entitu?

DTO (Data Transfer Object) je prostý objekt bez logiky, jehož úkol je přenést data přes hranici (mezi API a klientem) ve správném tvaru. Databázovou entitu neposílej přímo ze tří důvodů: únik citlivých polí (passwordHash, isAdmin), provázanost (změna databáze pak rozbije klienty) a jiný tvar (klient chce data jinak, než leží v databázi). Proto na hranici sestavíš DTO jen s tím, co má klient vidět (response DTO), a u příchozích dat (request DTO) vezmeš jen povolená pole a zvaliduješ je.

Kdy je architektura over-engineering?

Když přidává abstrakce, které neřeší žádný současný problém. Pět vrstev a tucet rozhraní na jednoduchou appku se třemi endpointy = ceremonie, co jen zpomaluje. YAGNI (You Aren't Gonna Need It): začni jednoduše (klidně controller → service → ORM) a přidávej strukturu, teprve když složitost reálně roste. Architektura má sloužit, ne být sama sobě cílem.