Nemakej v korporátu a přidej se k nám!

Hexagonální architektura ve Webnode

06.11.2023

Pokud někdo navštívil náš OpenDay v září 2022, mohl slyšet a vidět naši přednášku o hexagonální architektuře. Nechtěli jsme poučovat a vysvětlovat teorii, ale ukázat, jak jsme tento přístup pochopili a jak ho implementujeme u nás. Dvacet minut není moc času a do PowerPointu se nevleze všechno. Proto jsme se rozhodli vše rozepsat trochu víc do článku. Třeba někomu pomůže pochopit, co v praxi hexagonální architektura přináší a jak může vypadat v produkčním kódu. Možná nám někdo vysvětlí, že vůbec nevíme, co děláme a všechno děláme špatně. Rádi se v takovém případě necháme poučit 😊

Obsah

O čem je řeč

"Hexagonal architecture"/"Ports and Adapters" (Alistair Cockburn), "Onion architecture" (Jeffrey Palermo) nebo "Clean architecture" (Robert C. Martin). Různé pojmy od různých autorů pro podobná řešení architektury. Jde o přístupy k architektuře aplikací, které mají jako jeden z hlavních cílů oddělení hlavního aplikačního kódu a infrastruktury.

Změna databázového systému nesmí ovlivnit naší hlavní aplikační logiku. Výpočet slevy objednávky je počítán vždy stejně, bez ohledu na to, jestli se počítá pro zobrazení na webu nebo v rámci CLI cronu. Jestli se ve výsledku objednávka uloží do MySQL nebo PostgreSQL databáze neovlivní, jak s objednávkou pracujeme v hlavním kódu. Změna názvu databázového klíče nesmí způsobit změnu napříč aplikací. Zní to jako naprosto základní pravidla, která přece každý dodržuje. Ze zkušenosti bych ale řekl, že tomu tak není.

Každý asi někdy pracoval na starší aplikaci (nebo možná i novější 😊), kdy se pole načtené z DB vesele předávalo do všech částí aplikace a třeba i rovnou vrátilo jako výsledek API. Jakákoliv změna DB je pak prakticky nemožná, protože se hned projeví na výstupu z aplikace. Bobtnající kontrolory (nebo presentery) plné business logiky, provázané s HTTP natolik, že jsou prakticky netestovatelné. Zatoulaná POST proměnná, ze které se v repositáři berou data pro SQL. Toto jsou všechno problémy, které nám můžou znepříjemnit změny hlavní business logiky, testovatelnost nebo znuvupoužitelnost kódu.

Hexagonální architektura

S pojmem hexagonální architektura (nebo také Porty a adaptéry) přišel v roce 2009 Alistair Cockburn. Jako cíle této architektury uvádí možnost ovládat aplikaci různými způsoby (uživatel, jiný program, automatický test, …) a možnost vývoje aplikace v izolaci od infrastruktury na které poběží.  

Zdroj: https://alistair.cockburn.us/hexagonal-architecture/
Zdroj: https://alistair.cockburn.us/hexagonal-architecture/

V jádru celé aplikace je "Application". Ta představuje tu nejdůležitější business logiku, to hlavní, co nám vydělává peníze. Na hranici "Application" jsou "Ports". "Port" je rozhraní, které umožňuje přístup k funkcím aplikace nebo poskytuje aplikaci zdroje. Na tato rozhraní můžeme připojit různé zdroje dat, služby nebo ovladače aplikace. "Adapters" jsou už konkrétní technologie, které aplikace používá nebo které ji ovládají. Může to být databáze, API, HTTP kontroler, CLI příkaz apod. Pro více informací o teorii hexagonální architektury je nejlepší jít přímo ke zdroji.

Porty a Adaptéry se dají rozdělit na řídící/primární a řízené/sekundární. Tento pohled má hezky rozkreslený a popsaný Juan Manuel Garrido de Paz. Přijde mi líp pochopitelný, protože odpovídá tomu, jak jsem zvyklý o aplikaci přemýšlet. Naše dělení tříd potom dost vychází z tohoto pohledu na aplikaci. 

Zdroj: https://jmgarridopaz.github.io/content/hexagonalarchitecture.html
Zdroj: https://jmgarridopaz.github.io/content/hexagonalarchitecture.html

Někdo by se mohl pozastavit nad tím, proč vlastně hexagonální architektura, proč se zobrazuje v nákresech zrovna hexagon. Reálně nemá žádný hlubší význam. Tvar byl vybrán, aby kolem něj bylo dostatečné množství místa na kreslení různých portů a adapterů. Nebo to je aspoň jedno z udávaných vysvětlení.

Explicit architecture

Výš jsem popsal v krátkosti hexagonální architekturu. Přistup, který používáme my, vychází z hexa architektury, ale mírně ji rozšiřuje. Jejím autorem je Herberto Graça a označuje ji jako "Explicit architecture".

Hlavním, co jsme si z tohoto přístupu vzali, je jasnější oddělení "driving" a "driven" adapterů. Když se podíváme např. na kontroler pro zpracování HTTP requestu REST API a repositář, který je napojený na databázi, můžeme říct, že oboje jsou adaptéry pro okolí aplikace spojené s infrastrukturou a oba by tak mohly být v aplikaci na stejném místě. Z pohledu aplikace se ale liší. Pokud chci řešit způsoby jakým je aplikace volána, nemusí mě až tak zajímat, jaké zdroje používá. Z tohoto důvodu a také proto, aby se nám snadněji rozdělovali třídy do adresářů, oddělujeme oba druhy adaptérů od sebe. Více se dá dočíst na webu autora.

Domain driven design – jak souvisí s hexa architekturou?

S pojmem Domain driven design se nejspíš setkala většina z nás. Jedná se o přístup modelování domény, kterou aplikace zpracovává, kterou se firma zabývá. Modelování jádra domény, toho hlavního, co děláme. Hexagonální architektura umožňuje oddělit hlavní činnost aplikace od technického okolí. Otevírá nám tak cestu k použití DDD v naší aplikaci. Doména, kterou modelujeme, zapadá do "Application" části. Porty je možné psát tak, aby odpovídali ACL (anticorruption layer) vzoru z DDD.

Ve Webnode se učíme přemýšlet o našich aplikacích podle DDD a modelovat tak naše třídy. Aplikační část představuje Doménu jednotlivých aplikací. Naše "mikroslužby" odpovídají "Bounded context", které ve firmě máme. Snažíme se zavést "Ubiquitous Language" (moje oblíbené slovní spojení), abychom si všichni rozuměli. Naučit se fungovat s DDD není nic jednoduchého, přepsat existující aplikace je ještě těžší. Hexagonální architektura je přístup, který umožní začít. V našich aplikacích a tomto článku se budou dále míchat pojmy Application a Domain.

Jak vypadají naše aplikace

Dost bylo teorie, ukážeme si kousky struktury aplikace a kódů, kterou jsme nedávno tvořili od začátku. Díky tomu jsme mohli vše psát hexa stylem s DDD. V ukázkách půjdeme od "Primary Actors" po "Secondary Actors", zleva doprava. Odpovídá to přijetí requestu, jeho zpracování, použití DB apod. Aby se vše míň pletlo (snad), budu dál používat spíš označení portů a adaptérů a "actors", kterým dělení naší aplikace více odpovídá.

Primary actors - Driver adapters 

"Primary actors" neboli "driver adapters". Třídy, které řeší, jak bude s naší aplikací interagováno, řeší její vstupy. V naší aplikaci jsou to kontrolery pro zpracování HTTP API requestů, CLI příkazy a třídy, které zpracovávají eventy. Vstupy těchto tříd jsou závisle na technologii (HTTP request, CLI argumenty, Kafka událost).

Vstupní data se převedou na doménový objekt, který je na technologii nezávislý, a provolají aplikační třídu, která provádí business logiku. Výstupem z aplikační vrstvy je zase nějaký doménový objekt, který se zde převede na výstup odpovídající technologii. Např. Entita se převede na pole, a to se předá JSONu na výstup API. Samotná entita nemá ponětí o tom, jak se formátuje, jak se jmenují její klíče na výstupu apod. Řeší se to jen zde. Abychom oddělili zodpovědnost a umožnili lepší testování, nedělá vše jen Controller. Pro tvorbu výstupů používáme rádi transformery. Jednoduchá třída, s jednoduchým testem, nám vytvoří z entity pole pro odpověď.

V "driver" adaptérech také řešíme převod výjimek. Aplikace vyhazuje doménové výjimky, které ale mají význam pro aplikaci. Error zprávy a kódy jsou aplikační, nezávislé na technologii. Nikdy nám tak třeba repozitář nevyhodí výjimku NotFoundException s kódem 404, kterou bychom následně vyhodili v kontroleru jako výstup z aplikace. Pokud by stejný kód použil třeba CLI příkaz, kód 404 nemá význam. Proto se převod na chybové zprávy/kódy opět řeší v každém adaptéru zvlášť podle jeho technologie.

Application – To hlavní  

Driver ports

"Driver ports" jsou na hranici "Application" části. Jsou to třídy, které jsou volány z "Driver adapters" (např. z kontroleru, CLI příkazu nebo jiného "driver" portu). Představují způsoby, jakým můžeme aplikaci použít, co vše umí. Třídy umístěné v této části označujeme jako "UseCase" a mají "application" namespace. I když to může být ze začátku matoucí, že náš "application" namespace je jen částí hexagonu, pomáhá to zpřehlednění aplikace. "Driver" port může použít jen třídy z "application" namespace (jiný driver port) nebo z "domain" namespace. Tyto třídy neví nic o infrastruktuře ani okolním světě.

Driven ports

Toto už je skutečně to hlavní, co aplikace obsahuje, vnitřek hexagonu. Tyto třídy spojují vše v aplikaci. Proto tuto část označujeme jako "domain". Obsahuje entity, doménové služby, výjimky apod. Třídy můžou používat jen jiné části domény. Nikdy se zde nesmí vyskytnout něco z jiné vrstvy, doména je nezávislá. Její třídy se ale objevují ve všech ostatních vrstvách.

Rozhraní, která jsou v "domain" vrstvě odpovídají "driven" portům na pravé straně hexagonu. Na ně se potom budou napojovat implementace databází, API, SDK atd. 

Secondary actors – Driven adapters  

"Secondary actors" jsou na pravé straně hexagonu. V aplikaci je umisťujeme do namespace "infrastructure". Jsou to konkrétní implementace pro DB, API, SDK, queue apod. a implementují zmíněná doménová rozhraní. Tato vrstva používá jen jiné třídy z infrastructure nebo z domény. Nevidí na vstupy do aplikace. Je třeba dát si pozor, aby se žádná nepřevedená výjimka nedostala z infra vrstvy dál do aplikace (časté PDOException, GuzzleException nebo cizí výjimky z balíčků). Stejně tak žádný cizí objekt se z infra vrstvy nesmí dostat do aplikace. Chceme zajistit nezávislost na cizích třídách.  

Kdo propojí driven porty a adaptéry?

Z popisu naší aplikace vychází, že vše je oddělenné, spojené rozhraními a nikde v aplikaci nevíme, co se kde skutečně použije. To, co o tomto rozhoduje, je Dependency Injection Container. DIC propojí infra vrstvu, s konkrétní implementací, a doménové rozhraní. Teoreticky je tak možné vyměnit technologi změnou jen na jednom místě a zbytek aplikace nic nepozná. Výměnu jednoho databázového enginu za jiný neděláme často. Co děláme častěji je např. přidání cachovaní. V takovém případě jen vytvoříme novou infra třídu s cache adaptérem a implementujicí doménové rozhraní. Následně přebindujeme a vše běží dál, jen nově s cachováním. 

Příklad zpracování požadavku na získání domény

Pro lepší představu, jak vypadá průchod požadavku naší aplikací, si projdeme příklad požadavku na získání detailu domény.

Driver adapter – Controller

Máme aplikaci, která obsahuje API. Framework zpracuje HTTP požadavek, z něj získá data pro Controller a podle routovacích pravidel ho zavolá. Controller má injectovaný UseCase, který použije pro vykonání logiky a Transformer pro vytvoření odpovědi.  

Pokud by vstupní data byla složitější, vytvořili bychom si aplikační (případně doménový) objekt, který bychom si předali dál. Nikdy nepředáváme přímo HTTP objekt nebo pole. Nechceme, aby byla aplikace závislá na klíčích, které dostaneme na API.

Doménové výjimky, které by mohl vyhodit UseCase převedeme na výjimky, které už mají význam v HTTP nebo rovnou na chybové odpovědi. Zde už mohou obsahovat HTTP chyby.

Výstup z UseCase zpracuje transformer a předá třídě, která už tvoří HTTP odpověď.

V Controlleru se mohou vyskytovat třídy z použitého frameworku. Zbytek aplikační části je v podstatě framework agnostic, nezávislý. Snižuje se tak šance, že změna frameworku si vynutí změnu doménové logiky. Změna je potencionálně jednodušší. 

Driver port – UseCase 

UseCase třída je v tomto případě dost jednoduchá. Zde se jedná jen o provolání doménového rozhraní. Někdy může být UseCase třída zbytečná a lze z controlleru provolat rozhraní rovnou. UseCase ale tvoři lepší přehled co všechno aplikace umí. Každý ve vývojovém týmu lépe vidí, co za "schopnosti" aplikace má vystavené pro "driver" adaptéry.  

Driven adapter – Repository  

Repozitář je konkrétní implementací doménového rozhraní. Kromě přímé implementace infrastruktury (MySQL, Elastic, etc.) může i skrývat legacy implementaci, jako v ukázce. Tady jde o složitější volání různých API. Hlavní aplikace o tomto vůbec neví. Pokud se nám v budoucnu podaří nahradit za novější řešení, nahrazení proběhne jednoduše jen zde. Nová aplikace je tak odstíněná.

Důležité je zde převést všechny výjimky na doménové. Stejně tak i objekty a pole nemůžeme vrátit jen tak. U objektů i polí je třeba je převést na doménové abychom nebyli v aplikaci závislí na externí implementaci.  

Výstup pro driver – Transformer  

Jak bylo zmíněno, o formátování výstupu aplikace se stará transformer. Ten převede doménovou entitu do potřebné podoby. V tomto případě je výstupem JSON pole, které vznikne zde. Doménová entita o tomto neví, nemá žádné funkce "toArray" nebo podobně. Nechceme, aby kvůli změně API bylo třeba měnit doménovou entitu. Ta je od výstupů odstíněná.  

Co nám přináší hexagonální architektura? Jaké vidíme výhody?

Implementace a zavádění hexagonální architektury může vypadat složitě. Občas se zdá, že vytváříme větší množství kódu zbytečně. Když nám SDK vrátí v infrastructure vrstvě svůj "krásný" objekt s daty a funckionalitou, proč psát vlastní, který bude umět jenom část? Proč odchytávat každou výjimku? Proč si u entity nedržet, jaký formát má mít na výstupu, nebo si z API vstupu nevzít pole, a to rovnou zpracovat pro vložení do databáze? Zkusím zmínit alespoň některé z výhod, které vidíme.

Testovatelnost

Psaní testů je pro nás důležité a skutečně se o to snažíme, jen si to neříkáme. Hlavní kód naší aplikace, doména, je naprosto nezávislá na infrastruktuře, na které je provozována. Doménová rozhraní, která třídy používají, jsou jasně definované kontrakty, říkající, jak se aplikace bude chovat. I s použitím mocků, nebo testovacích implementací, jsme tak schopní testovat hlavní logiku.

Implementace nových požadavků s použitím TDD přístupu (test driven development) je jednoduché. Začnu samotnou logikou, která je požadovaná, a chystám si rozhraní. Odkud mi přijde požadavek, kde vezmu data, nebo jak bude naformátovaná odpověď, mě v tuto chvíli nezajímá. Soustředím se na to nejpodstatnější. Na závěr přidám infrastrukturní implementaci a provolání mého use case a vše propojím v DIC. Než jsem si na tento přístup zvykl, zdálo se mi, že je to možná pomalé a zbytečně náročné. Když ale člověk takto nachystaný kód spustí a na první pokus vše zafunguje, na code review se mu objeví míň připomínek, protože kód je přehlednější, zjistí, že to cenu má.

Nahraditelnost

Jak jsem zmínil, tak náhrada celého databázového systému nebo frameworku, není něco, co děláme často. I tak je ale dobré na to mít aplikaci nachystanou. Kdy jsme určitě ocenili naši architekturu byly případy, kdy pro určitou část naší týmové domény vznikla nová mikroslužba (např. Starající se o doménu faktur). Aplikace do té doby přistupovaly přímo do databáze faktur, nově ale měla použít naše SDK pro přístup k REST API. Tam, kde byla hexa architektura se provedlo jednoduše přidáním nové implementace rozhraní a změnou v DIC.

Druhý případ, kdy se hodí poměrně často, je přidávání cachování. Na začátku nemusíme vědět, která data se vyplatí cachovat a která ne. Jakmile ale zjistíme, že některý dotaz do databáze nebo jiné aplikace je náročný a zbytečně nám zpomaluje aplikaci, chceme přidat cachovaní. V takovém případu přidáme opět novou implementaci doménového rozhraní. Tato bude obsahovat rozhraní pro přístup do cachovacího systému a injectovanou současnou implementaci doménového rozhraní. Stará implementace se jen obalí cachováním a upraví DIC. Aplikace opět nic neví, fungující a produkcí ověřený kód se nemusel změnit a máme hotovo.

Struktura kódu

Díky hexagonální architektuře nám vzniká přehledněji rozdělený kód. Jednotlivé vrstvy jsou v samostatných namespacech. Hlavní aplikační kód je oddělený od vstupů do aplikace a od přístupů k jiným systémům.

Pokud jako nováček přijdu k aplikaci, vidím snadněji způsoby, jakým se aplikace může volat, díky oddělení "Driving" adaptérů (kontrolery, commandy,...). V aplikační části mám use casy, které mě pomůžou se zorientovat v tom, co vše aplikace vlastně umí, k čemu ji můžu použít. V infrastrukturní vrstvě zase hned vidím, na jaké technologie je aplikace napojená. A to hlavní v aplikaci mám uzavřené v doméně.

Rozdělování na jednotlivé vrstvy nás také nutí k tomu abychom psali menší třídy, oddělovali logiku z kontrolerů, chystali továrny na objekty apod.

Doména

Díky oddělení od vrstev a formování hlavní aplikační logiky zvlášť od ostatních, můžeme jasněji přemýšlet o doméně aplikace a jejím modelování. Jsme odstínění od implementačních detailů, názvosloví, které nám vnucuje infrastruktura nebo cizí balíky.

Nezávislost vrstev

Díky oddělování vrstev se nám nemůže stát, že by změna v DB (třeba nový sloupec, změna názvu sloupce) měla výrazný dopad na aplikaci nebo její výstup. Jediná změna, která se objeví, je v infrastrukturní vrstvě. Jinde se nás nedotkne.

Kam se chceme posunout dál?

Rozšíření do všech aplikací a přepis starých kódů

Nové aplikace a kódy píšeme tímto přístupem. Aplikací ale spravujeme více. Ve většině aplikací, které aktivně vyvíjíme již máme strukturu pro hexa alespoň zavedenou, i když obsahuje jen pár tříd.

Staré kódy přepisujeme postupně také, ale není to hned. Výhodou je, že není třeba přestat s vývojem nových feature a přepsat celou aplikací. Lze postupně v iteracích vylepšovat. Jakmile sáhneme na starší kód, zvážíme, jestli ho chceme přesunout, případně jak velkou část, a v rámci nového vývoje zavádíme a převádíme.

Je to dlouhý proces, ale umožňuje nám uspokojit business požadavky zákazníků, ale i naše představy o vylepšování kódu a odstraňování technického dluhu.

Naučit se pořádně DDD

Stále se učíme přemýšlet o doménách aplikací. Při práci na úkolech, v komunikaci v týmech, zavádíme "ubiquitous" jazyk, abychom si všichni rozuměli. Učíme se modelovat procesy. Stále to ještě neumíme a máme se co dál učit.

Nedělat hloupé chyby v návrhu

V tomto myslím, že jsme se od mé přednášky zlepšili. Děje se stále měně často, že nám uteče infrastrukturní výjimka do aplikace, nebo použijeme pole z databáze někde jinde. Nikdy ale nebudeme dokonalí.

Podařilo se nám v jedné z aplikací použít nástroj deptrac, který dokáže provést statickou analýzu architektury. Hlídá nám tak právě to, aby se nám naše vrstvy nepomíchaly. Chceme rozšířit do všech aplikací, kde máme hexa architekturu.

Závěr

Co říct závěrem? Určitě máme v týmu pocit, že se nám hexagonální architektura vyplácí. Kódy jsou čistější, testovatelnější, přehlednější a lépe udržitelné. Zavádění nových feature je rychlejší a děláme méně chyb.

Možná v našem přístupu děláme něco špatně, něco jsme nepochopili, nebo si upravili podle svého. Slyšel jsem od pár bývalých kolegů, že přece toto všechno, co popisuje hexagonální architektura je jasné, a dělá to tak každý. Moje zkušenost říká, že tomu tak není a mít jasně definovaná a pojmenovaná pravidla, se vyplácí.

Pokud má někdo jiný názor, nesouhlasí s naším přístupem, tak dejte vědět. Můžeme to probrat v komentářích, na nějakém meetupu, nebo třeba na pohovoru v naší firmě 😊. Rádi se dozvíme něco nového, co nám umožní pracovat lépe. 

Za Webnode Jan Vacula a Tomáš Vaverka