Ubiquitous language: Jak ho vynutit v Symfony projektu?

Ubiquitous language není slovník pro celou firmu, ale jazyk jednoho bounded contextu. Šest praktik, jak ho udržet v Symfony projektu, včetně toho, jak ho nechat hlídat CI místo reviewera.

// obsah 11
  1. 01 Co to vlastně je ubiquitous language?
  2. 02 Proč to nikdo nedělá?
  3. 03 Praktika 1: Glossary v repozitáři, jeden na kontext
  4. 04 Praktika 2: Kontrola jazyka v code review
  5. 05 Praktika 3: Testy jako živá dokumentace
  6. 06 Praktika 4: Doménové události v minulém čase
  7. 07 Praktika 5: Přejmenování jako samostatné PR
  8. 08 Praktika 6: Ať to hlídá stroj
  9. 09 Co s českou doménou a anglickým kódem?
  10. 10 Shrnutí
  11. 11 Zdroje

Ubiquitous language je asi nejcitovanější princip z celého DDD. Nejtěžší na něm ale je to, jak ho udržet v projektu víc než pár měsíců.

Vídám to pořád. Klient na callu řekne „zákazník zahájil objednávku“, v zápisu z jednání skončí „buyer initialized transaction“ a v kódu je UserSession::createCart(). Tři jména pro jednu věc a tři místa, kde vzniká nedorozumění.

Co to vlastně je ubiquitous language?

Dá se to shrnout do jedné věty (Eric Evans): uvnitř bounded contextu používejte stejný jazyk v diagramech, v psaném textu a hlavně v mluveném slovu. Ta poslední část je důležitá. Zapomíná se na ni. Jazyk se totiž rozchází na callu, kde jedna strana říká „objednávka“, druhá „transakce“ a obě si myslí, že mluví o tomtéž.

Dvě věci na tom zdůrazňuju, protože je většina článků o ubiquitous language vynechá.

„Ubiquitous“ neznamená „univerzální“. Nejde o firemní slovník platný napříč vším, co máte v produkci. Jde o jazyk jednoho bounded contextu. Evans to má rovnou ve své třívětné definici DDD, kde třetí bod zní, že se mluví jedním jazykem uvnitř výslovně vymezeného kontextu. Slovo Customer může v Sales znamenat někoho, kdo má rozjednanou nabídku, v Billing někoho, komu se fakturuje, a v Supportu někoho, kdo má nárok na podporu. Sjednotit je do jednoho pojmu by byla chyba. Jsou to tři různé věci, které mají náhodou stejné jméno, a nakreslit mezi nimi hranici je téma samo o sobě (mám ho v článku o bounded contextech).

Změna jazyka je změna modelu. Proč se o slovíčka vůbec přít? Protože spor o jméno bývá spor o návrh. Když se tým dohaduje, jestli se to jmenuje „objednávka“ nebo „poptávka“, nebaví se o názvu třídy. Baví se o tom, v jakém okamžiku vzniká závazek (jenom o tom zatím neví). Pokud takový spor skončí tím, že se prostě vybere jedno slovo a jde se dál, přišli jste o informaci (i mně to chvíli trvalo pochopit).

Ubiquitous language přitom neznamená, že přestanete používat technické termíny. Handler, dispatch, middleware, to jsou koncepty infrastruktury a ty zůstanou. Ale Customer místo User, Order místo Transaction, initiate() místo create(), to je doménový jazyk a ten konzistentní být musí.

Proč to nikdo nedělá?

Důvody bývají tři a ani jeden z nich není lenost.

Klient mluví česky, kód je anglicky. Klient řekne „košík“, já napíšu Cart a zatím je všechno v pořádku, protože jsme to jen přeložili. Jenže pak někdo napíše Basket, někdo jiný ShoppingSession, a za rok máte v jednom kontextu tři pojmy pro jednu věc. Překlad je u českých projektů nevyhnutelný, ale musí být jeden a musí být někde zapsaný, jinak si ho každý udělá po svém (jak to řeším u sebe, píšu na konci).

Historické konvence bolí měnit. Projekt běží dva roky, UserSession je v padesáti souborech, v databázové tabulce a v logách. Přejmenovat to znamená pár dní práce a migraci, u které se nikomu nechce riskovat produkci, takže se to odloží. Místo toho se přidá nový kód se správným jménem, starý zůstane, a zmatek je větší než předtím, protože teď existují obě varianty vedle sebe.

Bez výslovné dohody si každý vymyslí vlastní jména. Tým nikdy neměl konverzaci o tom, jak se co jmenuje. Každý byl na jiném callu, pochopil to o kus jinak a napsal to podle sebe. Bez slovníku není co citovat v code review a diskuze se scvrkne na to, čí verze zní líp.

No, a proti všem třem existuje šest konkrétních praktik.

Schéma ukazující, že slovo Customer má v kontextech Sales, Billing a Support tři různé významy, a že jeden společný firemní slovník je proto slučuje chybně, zatímco slovník na každý kontext je drží oddělené

Praktika 1: Glossary v repozitáři, jeden na kontext

Založte soubor docs/glossary-<kontext>.md. Ne Confluence stránku, ne Notion, prostě Markdown v repozitáři, aby byl na stejném místě jako kód.

Podstatné je to <kontext> v názvu. Jeden soubor na celou aplikaci je přesně ta chyba, kvůli které pak Customer znamená tři věci najednou.

# Glossary: Sales

## Customer (Zákazník)

Osoba nebo firma, se kterou je rozjednaná nabídka. V Sales ještě nemusí
mít žádnou fakturu ani účet.

**V tomhle kontextu nepoužívat:** User, Buyer, Client, Purchaser
**Pozor:** `Customer` v Billing je jiný pojem, viz glossary-billing.md

---

## Order (Objednávka)

Záměr zákazníka nakoupit konkrétní položky. Vzniká zahájením checkoutu,
ne přidáním do košíku, a závazným se stává až ve stavu Confirmed.

**V tomhle kontextu nepoužívat:** Transaction, Purchase, Request
**Stavy:** Pending → Confirmed → Shipped → Delivered
**Události:** OrderPlaced (vznik), OrderConfirmed, OrderShipped, OrderDelivered

Řádově desítky pojmů na kontext, každý česky i anglicky, s popisem a s výslovným zákazem synonym. Glossary se pak udržuje jako kód. Pull request, který zavádí nový termín, buď použije pojem, co už tam je, nebo glossary rozšíří. Když reviewer narazí na termín, který ve slovníku není, vrátí PR k doplnění.

Praktika 2: Kontrola jazyka v code review

Glossary bez vymáhání je hezký dokument, který nikdo nečte. Vymáhá se v code review a stojí to jednu otázku navíc v checklistu: sedí názvy tříd, metod a proměnných s glossary toho kontextu? Část z toho jde pak přesunout na stroj, k tomu se dostanu u praktiky 6.

V praxi to vypadá tak, že reviewer narazí na UserSession, otevře slovník, „User“ tam není a je tam Customer. Komentář zní „prosím přejmenovat na CustomerSession podle glossary“. Hotovo. Není to perfekcionismus, každý nekonzistentní termín je dluh, který zaplatíte při onboardingu nováčka nebo na příštím callu s klientem, kdy nikdo nebude vědět, o čem se mluví.

V Symfony se konzistence pozná i na struktuře src/. Když máte adresáře podle domény (src/Sales/, src/Billing/, src/Catalog/) a jména v nich sedí s příslušným slovníkem, je to dobrý signál. Když v Sales najdete User a Transaction, zatímco glossary-sales.md zná Customer a Order, máte co přejmenovávat. Pozor ale na to, že samotné src/Identity/ s User uvnitř je úplně v pořádku, protože tam je User to správné slovo. Nekontrolujete jméno proti jednomu seznamu, ale proti slovníku toho kontextu, ve kterém stojíte.

Praktika 3: Testy jako živá dokumentace

Nejlepší živá dokumentace, jakou znám, jsou BDD scénáře v Gherkinu, tedy Behat.

Feature: Zahájení objednávky

  Scenario: Zákazník zahájí checkout s položkami v košíku
    Given zákazník přidal položku "Notebook Lenovo X1" do košíku
    And zákazník zadal doručovací adresu
    When zákazník zahájí checkout
    Then vznikne objednávka ve stavu "Pending"
    And zákazník obdrží potvrzení e-mailem

Samotný .feature soubor ale nedělá nic. Scénář ožije až tím, že ke krokům dopíšete definice:

use Behat\Behat\Context\Context;
use Behat\Step\Given;
use Behat\Step\When;

final class OrderContext implements Context
{
    #[Given('zákazník přidal položku :nazev do košíku')]
    public function zakaznikPridalPolozku(string $nazev): void
    {
        // …
    }

    #[When('zákazník zahájí checkout')]
    public function zakaznikZahajiCheckout(): void
    {
        // …
    }
}

Token :nazev je to, co z "Notebook Lenovo X1" udělá argument metody. Bez něj je uvozovkový text jen součást věty, kterou Behat hledá doslova.

Všimněte si, že tam není žádný createCart() ani technická vata. „Zákazník zahájí checkout“ je věta, kterou můžete říct klientovi a on ji pochopí. To je ten test, jestli scénáře mluví doménovým jazykem, nebo ne.

Klíčová slova Feature, Scenario a Given/When/Then jsou schválně anglicky. Je to syntaxe nástroje, ne doména, stejně jako class nebo function, takže se na ně pravidlo o jednom jazyce nevztahuje. Když je chcete česky i tak, Behat to umí přes hlavičku # language: cs a klíčová slova pak zní Požadavek, Scénář, Pokud, Když a Pak.

Nováček by měl na projektu začínat právě těmihle scénáři. Když z nich pochopí, co systém dělá, aniž by otevřel kód, jste na dobré cestě.

Praktika 4: Doménové události v minulém čase

Malá konvence, která funguje. Doménové události jsou vlastně taková historická fakta, ne příkazy, takže patří do minulého času:

namespace App\Sales\Domain\Event;

// Správně – minulý čas, něco se stalo
class OrderPlaced {}
class CustomerRegistered {}
class ItemAddedToCart {}

// Špatně – imperativ nebo neutrální „event“
class PlaceOrderEvent {}
class RegisterCustomer {}
class CartItemEvent {}

OrderPlaced nese informaci, že se to stalo. PlaceOrderEvent zní jako pokyn, aby se to stalo, a to je jiná sémantika. V Symfony si z toho udělejte pevné pravidlo: událost je psaná v minulém čase, je v namespace Domain\Event a ten název je pojem z glossary. Když se událost jmenuje UserTransactionInitiated a slovník zná OrderPlaced, něco nesedí buď v kódu, nebo ve slovníku.

Přes Messenger to má i praktický dopad. Příkazy pojmenujte rozkazovacím způsobem (PlaceOrder), události minulým časem (OrderPlaced), a v config/packages/messenger.yaml je pak z routingu na první pohled vidět, co se posílá do fronty a co se zpracuje hned.

Praktika 5: Přejmenování jako samostatné PR

Jednou za čas udělejte pull request, jehož jediný účel je přejmenování. Žádné zásahy do architektury, žádné přesuny mezi vrstvami, čistě jména tříd, metod a proměnných, která neodpovídají slovníku.

// Před
class UserSession {
    public function createCart(int $userId): Cart {}
    public function addProduct(int $productId, int $qty): void {}
    public function finalizeTransaction(): Order {}
}

// Po
class ShoppingCart {
    public static function initiate(CustomerId $customerId): self {}
    public function addItem(ProductId $productId, Quantity $quantity): void {}
    public function checkout(): Order {}
}

V Symfony se na tomhle nejvíc pálí Doctrine. Entita se klidně jmenuje Order, jenže pod ní visí #[ORM\Table(name: 'user_sessions')] a staré jméno žije dál. Slovník pak sedí v kódu a rozchází se v databázi, což je ta horší varianta, protože v kódu to nikdo nevidí.

Uvnitř PHP je přejmenování celkem bezpečné, IDE to zvládne za minutu. Databázové sloupce jsou už něco jiného. Tam už jde o migraci a u velké tabulky o zámek na produkci, takže je držte v samostatném PR a nasazujte postupně (nový sloupec, dvojí zápis, přepnutí čtení, zahození starého). Právě proto se přejmenování odkládá, dokud se z něj nestane krize. Po malých částech je to jednodušší.

Schéma toku jednoho pojmu ze slovníku do kódu: z hesla Order v glossary vede jméno do třídy, metody, doménové události, Behat scénáře a databázového sloupce, a pod tím dvě brány, které to hlídají, code review a CI

Praktika 6: Ať to hlídá stroj

Předchozích pět praktik stojí na dohodě a na disciplíně. Jenže dohoda, kterou nic nevymáhá, žije jen v tom dokumentu, a disciplína je to první, co jde stranou, když je toho hodně. Nejvíc pro jazyk uděláte tím, že ho dostanete do CI.

Zakázaná synonyma jako test. Sekce V tomhle kontextu nepoužívat: ve slovníku je strojově čitelná. Krátký skript z ní vytáhne zakázaná slova a projede jimi src/<Kontext>/. Když se Buyer objeví v Sales, build zčervená a nemusí se o tom diskutovat v pull requestu. Bacha na falešné poplachy, protože nejsou teoretické. Zakázané Client v Sales spolehlivě trefí každý HttpClient a každou proměnnou $client v infrastruktuře. Hledejte proto celá slova a jen v adresáři toho kontextu, jinak si to lidi vypnou dřív, než jim to začne pomáhat.

Konvence událostí jako pravidlo pro PHPStan. Vlastní rule zkontroluje dvě věci, které se detekovat dají: že třída v Domain\Event nemá sufix Event a že její jméno stojí v sekci Události: příslušného slovníku. Minulý čas stroj nepozná. Rozhodne o něm člověk ve chvíli, kdy heslo do slovníku píše. Statická analýza pak hlídá jenom to, že se od toho rozhodnutí kód neodchýlí. Změnu jazyka nemůže udělat stroj, protože je to změna modelu.

Jako třetí do party se hodí Deptrac, který ohlídá, že si Sales nesáhne do Billing napřímo. To už sice není o jazyce, ale bez držených hranic se rozpadne i to, na který slovník se vlastně díváte.

Reviewer čte hlavně logiku a špatné jméno mu proklouzne, hlavně ve čtvrtek před releasem, kdy se odklikává rychle. Skriptu je jedno, kolikátého je.

Co s českou doménou a anglickým kódem?

Zvláštnost českých projektů: klient mluví česky, konvence PHP jsou anglické. Tohle rozhodnutí udělejte vědomě a pak ho držte, protože obojí zvlášť funguje a míchání ne.

Sám volím anglické identifikátory pro doménové koncepty (Customer, Order, Cart) a v komunikaci s klientem překládám. Je to konzistentní a slovník ten překlad drží na jednom místě. České identifikátory v kódu technicky fungují, jen narazíte na frameworkové konvence, na nástroje v IDE a na to, že celý ekosystém kolem je anglicky.

Co nedělám nikdy, je mix. Customer a Zakaznik v jedné codebase je horší než kterákoli z těch čistých variant. A protože je to rozhodnutí, na které se za rok někdo zeptá, patří do ADR, ne do hlavy.

Shrnutí

Těch šest praktik drží jazyk v artefaktech, ale samy o sobě z nikoho dobrého návrháře neudělají. Pořád mi na tom nejvíc sedí ta Evansova věta o mluveném slovu: nejvíc se toho o doméně dozvíte ve chvíli, kdy někdo řekne „takhle tomu ale my neříkáme“. V takový moment poslouchejte (a zaznamenávejte), protože v tu chvíli se nebavíte o pojmenování, ale o modelu.

Zdroje

Michal Katuščák
Michal Katuščák

Navrhuji a vyvíjím aplikace nad Symfony a Reactem, zajímám se architekturu softwaru. Žiju v Českých Budějovicích.