Architektura kódu

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

Co se naučíš: Poznáš, kam v kódu co patří, a proč se vrstvy dělají kvůli změnám v budoucnu, ne kvůli kráse.

19 min čtení + cvičeníNavazuje na:🔌 API Design

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.

A proč jen „lite"? Výše jsou taktické vzory DDD, stavební kameny kódu, co se hodí skoro vždy. Celé DDD má ještě strategickou vrstvu pro návrh ve velkém, když je doména složitá nebo na ní dělá víc týmů:

  • Bounded context (ohraničený kontext), hranice, uvnitř které má každý pojem jeden přesný význam. „Zákazník" ve fakturaci a „Zákazník" v podpoře jsou dva různé modely, nemíchej je do jednoho univerzálního User.
  • Subdomény, co je tvoje jádro (konkurenční výhoda, tam investuj čas), co jen podpůrné a co generické (kup/použij hotové, viz Tech Choices).
  • Context mapping / anti-corruption layer, jak spolu kontexty mluví a jak se jeden brání tomu, aby mu cizí model prosákl do jeho jazyka (překladová vrstva na hranici).
  • Domain events, kontexty se o změnách dozvídají přes události („objednávka zaplacena"), ne sáháním si navzájem do dat (viz Messaging a outbox v Patternech).

Celé DDD je investice, která se vyplatí u složité domény a víc týmů; na obyčejnou CRUD appku je to kanón na vrabce. Proto tady bereš jen ty taktické kousky a strategické sáhneš, až doména začne bolet.


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 a je to první krok k modulárnímu monolitu (níže).


Modulární monolit, hranice bez distribuovaného pekla

Mezi „jeden velký monolit, kde si všechno sahá na všechno" a „microservices přes síť" je zlatá střední cesta: modulární monolit. Je to pořád jeden nasaditelný program (jeden deploy, lokální transakce, žádná síťová režie), ale uvnitř rozdělený na moduly s tvrdými hranicemi a každý modul (users, orders, billing) je vlastně ten bounded context z DDD výše.

Co dělá modul modulem (a ne jen složkou):

  • Vlastní doména a vlastní data. Modul billing nesahá přímo do tabulek modulu orders. Když něco potřebuje, poprosí přes veřejné rozhraní druhého modulu, ne přes jeho databázi.
  • Komunikace jen přes public API modulu: pár funkcí/rozhraní ven, zbytek je skrytý (private). Uvnitř si modul dělá, co chce; navenek slibuje jen to rozhraní (může ho přepsat, aniž rozbije ostatní).
  • Žádné importy cizích vnitřností: orders nesmí importovat interní třídu z billing. Přesně tuhle hranici u obyčejného monolitu nikdo nehlídá, a proto se časem rozteče do „velké koule bláta".

Proč to chtít: dostaneš jasné hranice skoro jako u microservices, ale bez distribuovaného pekla, pořád máš lokální transakce, jeden deploy a snadný debug. A když jednou nějaký modul fakt potřebuješ odříznout do samostatné služby, hranici už máš hotovou: vyřízneš ho, protože nikdy nesahal cizím do dat. Proto je doporučená cesta monolit → modulární monolit → (teprve až to bolí) microservices, ne skok rovnou k microservices (viz Tech Choices).

🚩 Nejčastější podvod: „modulární monolit", kde moduly navzájem importují vnitřnosti a sdílejí tabulky. To je jen obyčejný monolit s hezčími názvy složek, hranice, kterou nikdo nevynucuje, žádná hranice není. Vynucuj ji (lint pravidla, oddělená schémata, code review), ne jen doufej.


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 User má id, 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.

Co je modulární monolit a čím se liší od obyčejného monolitu?

Je to jeden nasaditelný program (jeden deploy, lokální transakce, žádná síťová režie), ale uvnitř rozdělený na moduly s vynucenými hranicemi: každý modul má vlastní doménu i data, ven mluví jen přes veřejné rozhraní a nesahá cizím do tabulek ani neimportuje jejich vnitřnosti. Od obyčejného monolitu se liší právě tou vynucenou hranicí: běžný monolit ji nehlídá a časem se rozteče do „velké koule bláta". Výhoda oproti microservices: jasné hranice bez distribuovaného pekla a když modul jednou potřebuješ odříznout do služby, hranici už máš hotovou. Proto cesta monolit → modulární monolit → microservices, ne skok rovnou.

Proč učebnice bere jen DDD-lite a co přidává celé DDD?

DDD-lite = taktické vzory (entity, value object, aggregate, ubiquitous language), stavební kameny kódu, co pomůžou skoro vždy. Celé DDD přidává strategickou vrstvu pro složité domény a víc týmů: bounded contexts (hranice, kde má pojem jeden význam), subdomény (co je jádro vs. podpůrné vs. generické), context mapping / anti-corruption layer (jak kontexty mluví a brání se cizímu modelu) a domain events (dozvídají se o změnách přes události, ne sáháním do dat). Je to investice, která se vyplatí až u složité domény, na CRUD appku je to kanón na vrabce.