Anti-corruption layer v praxi: jak si nenechat rozbít doménu cizím ERP

Integrace s cizím ERP nebo API třetích stran umí zničit čistý doménový model. Anti-corruption layer je způsob, jak se chránit. Ukázka v PHP.

// obsah 5
  1. 01 Jak nám ERP otrávil doménu
  2. 02 Co to je anti-corruption layer?
  3. 03 Jak ji postavit v PHP/Symfony?
  4. 04 Kdy ACL potřebujete a kdy ne?
  5. 05 ACL funguje oběma směry

TL;DR: Integrace s cizím ERP nebo API třetích stran umí zničit čistý doménový model. Anti-corruption layer je způsob, jak se chránit. Zde je ukázka v PHP (záměrně trochu zjednodušená, ale vychází to z reálného případu).

Jak nám ERP otrávil doménu

Zažil jsem to a ne jednou. S týmem jsme měli krásně pojmenovaný doménový model: produkt měl productId, sklad byl warehouse, objednávka byla order se stavem confirmed nebo shipped (zkrátka přesné a jednotné pojmenování napříč celým systémem). Kód se dobře četl a nový vývojář rozuměl doméně za půl dne.

Pak přišel požadavek: „Potřebujeme to napojit na naše ERP.“ Vlastní, roky vyvíjený interní systém, takže žádná pěkná dokumentace, jen databáze plná zkratek.

Žádný problém, práce na pár dní, možná dva týdny. Vytvoříme API klienta, pár mapování a je hotovo. Kód vypadal takhle:

$product->setMatCislo($erpData['MAT_CISLO']);
$product->setStrKod($erpData['STR_KOD']);
$order->setDoklCislo($erpData['DOKL_CISLO']);

Produktová entita narostla o dvanáct nových polí. Půlka z nich byly zkratky, jejichž původ nikdo u nás neznal, ale pro jistotu to převedeme rovnou i k nám. Meetingy začaly být ve stylu: „je to NAZEV nebo MAT_NAZEV?“, „jak mapujeme DOKL_STAV na náš stav?“, „ERP říká stredisko, ale to je přece náš warehouse, ne?“. A další a další podobné případy.

Doména přestala mluvit řečí byznysu – mluvila slovníkem ERP.

Co se stalo? Tým vzal cizí model a natáhl ho přímo do domény. Nebylo to vědomé rozhodnutí – byla to přirozená zkratka pod časovým tlakem. „Prostě to namapujem.“ A najednou je celý doménový model zamořený cizím názvoslovím, kterému nikdo z byznysu nerozumí a které závisí na tom, jak data strukturuje ERP, a ne jak o nich přemýšlíme my.

Tomuhle říkám zamoření modelu a přesně to myslím tím „rozbít doménu“, jak píšu v nadpisu. Není to funkční rozbití aplikace, protože ta běží dál, ale doména ztratí vlastní jazyk, který jsme si vypiplali. Evans pro to používá slovo corruption – cizí model „kazí“ ten váš. A právě odtud má jméno i obranná vrstva: anti-corruption layer.

Co to je anti-corruption layer?

Eric Evans definoval anti-corruption layer v knize Domain-Driven Design jako izolační vrstvu mezi vlastním bounded contextem a externím systémem. Jejím smyslem je překlad mezi oběma modely – podle potřeby v obou směrech. Klíčové je, kterým směrem jde závislost. Bez vrstvy se vaše doména ohýbá podle ERP. Například se v ERP změní pole a vy kvůli tomu pak přepisujete doménu. S ACL je to jinak: nepřizpůsobuje se doména ani ERP, ale přizpůsobuje se vždycky jen ACL, vaše doména o ERP neví a ERP neví o doméně.

Vrstva má tři zodpovědnosti:

Schema mapping: jak vypadá payload. JSON z API se rozloží na objekty, pole MAT_CISLO se přejmenuje na productId, CENA_BEZ_DPH se převede na Money. Čistě formát a jména.

Concept translation: co data znamenají. Cizí koncept nemusí být totéž co váš doménový, i když má podobné jméno. ERP stredisko je účetní jednotka, někdy konkrétní sklad, někdy celá pobočka i s kanceláří. Doménový warehouse je vždycky jedno skladové místo. Překlad musí tuhle sémantiku zohlednit, ne jen přejmenovat pole.

Anti-corruption: dá se tomu vůbec věřit? Co se nedá přeložit nebo nesedí na kontrakt, vrstva odmítne, ještě než se to dotkne domény – neznámá hodnota enumu, chybějící povinné pole, neparsovatelný typ. V kódu níže to dělají právě ty throwy: neznámé STR_KOD i neznámý MAT_TYP skončí výjimkou, ne v doméně.

ACL hlídá přeložitelnost a důvěryhodnost payloadu, ne byznys pravidla. Že cena nesmí být záporná nebo datum v budoucnu, to jsou doménové invarianty, takže patří do domény (níže si zápornou cenu hlídá Product sám v reprice()), ne do překladové vrstvy. Jinak si do infrastruktury protáhnete přesně tu logiku, kterou se snažíte chránit.

Důležitý detail: ACL je víc než adapter nebo proxy. Adapter přizpůsobuje cizí rozhraní tvaru, který očekáváte. Proxy řídí přístup k objektu. Evans ACL skládá z fasády, adapteru a translatoru (Domain-Driven Design, kap. 14), ale to podstatné vlastně dělá translator, který překládá samotné koncepty mezi modely. Pokud externí systém říká „schválení objednávky“ a vy v doméně říkáte „potvrzení objednávky“, jde o sémantický rozdíl s byznys dopadem – a ACL je místo, kde to vyřešíte jednou a na jednom místě.

Srovnání, jak to vypadá bez vrstvy a s ní:

Situace Bez ACL S ACL
Nová pole z ERP Přidáváme je přímo do doménové entity Přidáváme je do External DTO, doména neví
ERP změní formát pole Doménový model se musí upravit Upravujeme jen Translator, doména beze změn
Nový vývojář čte doménový kód Musí znát ERP terminologii Čte čistou doménovou řeč
Testování doménové logiky Testy závisejí na ERP struktuře Testy pracují s čistými doménovými objekty
ERP odpadne nebo se vymění Refactoring napříč celou doménou Vyměníme Translator a External DTO

Anti-corruption layer funguje jako takový tlumič. Cokoli se děje na straně externího systému (například změna API, přejmenování polí, nová verze), tak bude mít vliv na ACL. Doménový model to ani nepocítí. Za mě je to důležité hlavně v tom, že to zmenšuje riziko chyb, které mohou nastat přepisem domény (což s ACL není potřeba). Změny probíhají v relativně malém uzavřeném celku.

Jak ji postavit v PHP/Symfony?

V mojí ukázkové PHP implementaci vrstvu rozložíme na tři části. Každá má jasnou zodpovědnost.

External DTO reprezentuje cizí formát věrně. Tady se ponechají cizí jména tak, jak jsou. Pokud ERP posílá MAT_CISLO, DTO má pole MAT_CISLO. Zde není prostor pro čistý kód. Je to ošklivé schválně, protože věrně odráží cizí realitu.

Translator je jediné místo v systému, kde dochází k překladu. Zná oba světy: rozumí externímu DTO i doménovému objektu. Sémantická logika překladu je tady, takže pokud platí podmínka, že „ERP stredisko s příznakem skladu je náš warehouse„, je to Translatorova zodpovědnost.

Domain object o externím systému neví vůbec nic. Žádný import na ERP jmenný prostor, žádný komentář „toto pole přichází z ERP“. Čistý doménový model.

<?php
// Infrastructure/Erp/Dto/ErpMaterialDTO.php
// Věrná reprezentace externího ERP - záměrně držíme cizí jména

final readonly class ErpMaterialDTO
{
    public function __construct(
        public string $MAT_CISLO,    // číslo materiálu v ERP
        public string $NAZEV,        // název materiálu
        public float  $CENA_BEZ_DPH, // cena v CZK bez DPH (haléře se musí dopočítat)
        public string $STR_KOD,      // kód střediska (sklad i pobočka)
        public string $MAT_TYP,      // typ materiálu (HV = hotový výrobek, SUR = surovina)
    ) {}
}
<?php
// Infrastructure/Erp/Translator/ErpMaterialTranslator.php
// Jediné místo, kde víme o obou světech

final class ErpMaterialTranslator
{
    public function __construct(
        private readonly WarehouseRepository $warehouses,
    ) {}

    public function toDomainProduct(ErpMaterialDTO $dto): Product
    {
        // Schema mapping: přejmenování a konverze formátu
        $productId = ProductId::fromString($dto->MAT_CISLO);
        $price     = Money::czk((int) round($dto->CENA_BEZ_DPH * 100)); // koruny → haléře

        // Concept translation: cizí "stredisko" přeložíme na náš WarehouseId
        $warehouseId = $this->warehouses->findIdByErpStredisko($dto->STR_KOD)
            ?? throw new ErpTranslationException(
                "Neznámé ERP středisko: {$dto->STR_KOD}"
            );

        // Concept translation: jejich typy materiálu → naše kategorie
        $category = match ($dto->MAT_TYP) {
            'HV'  => ProductCategory::FinishedGood,
            'SUR' => ProductCategory::RawMaterial,
            'POL' => ProductCategory::SemiFinished,
            default => throw new ErpTranslationException(
                "Neznámý typ materiálu: {$dto->MAT_TYP}"
            ),
        };

        return new Product(
            id:          $productId,
            name:        $dto->NAZEV,
            price:       $price,
            warehouseId: $warehouseId,
            category:    $category,
        );
    }
}

Tenhle translator sahá do WarehouseRepository, aby cizí klíč přeložil na náš WarehouseId. Je to vědomý kompromis – překlad a resolving reference drží na jednom místě za cenu závislosti na perzistenci. Pokud byste chtěli translator více čistý, tak nechte WarehouseId vyřešit volající službu a předejte ho do translatoru z ní.

<?php
// Domain/Catalog/Product.php
// Čistý doménový model - žádná zmínka o ERP ani MAT_CISLO

final class Product
{
    public function __construct(
        private readonly ProductId       $id,
        private readonly string          $name,
        private Money                    $price,
        private readonly WarehouseId     $warehouseId,
        private readonly ProductCategory $category,
    ) {}

    public function reprice(Money $newPrice): void
    {
        if ($newPrice->isNegative()) {
            throw new InvalidPriceException('Cena produktu nemůže být záporná.');
        }
        $this->price = $newPrice;
    }

    // Byznys metody v doménové řeči - žádný ERP slovník tady
    public function isSellable(): bool
    {
        // Zákazníkům prodáváme jen hotové výrobky, ne suroviny ani polotovary
        return $this->category === ProductCategory::FinishedGood;
    }
}

Product má metody jako reprice() a isSellable(). Mluví o byznysu. Nic v něm nenaznačuje, že přišel z ERP. Mohl klidně přijít z REST API, z Excelu nebo z CSV – doménový model to nevidí a ani vědět nechce.

Kdyby ERP zítra přejmenoval CENA_BEZ_DPH na CENA_NETTO, upravíte jeden řádek v ErpMaterialTranslator. Doménový model, doménové testy, byznys pravidla – to všechno zůstane beze změny.

Kdy ACL potřebujete a kdy ne?

Vrstva má smysl tam, kde je cizí model skutečně jiný než ten váš a kde je integrace stabilní (tj. budete s ní žít dlouho). Jinými slovy: musí se ta práce navíc vyplatit.

Potřebujete ACL:

  • Integrace s custom ERP, Oracle ERP, Microsoft Dynamics nebo jinou platformou
  • Externí SaaS s vlastním doménovým slovníkem (platební brána, logistický systém)
  • Každý případ, kde cizí API používá jinou terminologii než vaše doména

Pravděpodobně nepotřebujete ACL:

  • Vaše vlastní interní služba, která mluví stejnou řečí (shared ubiquitous language)
  • Jednoduché CRUD integrace, kde pole sedí 1:1
  • Prototyp nebo jednorázový import, který za měsíc zmizí

Nejčastější chyba je přesně opačná: tým přidá ACL ke každé integraci „pro pořádek“, i tam, kde žádný překlad potřeba není. Vrstva pak vzniká vlastně prázdná, jen přejmenuje id na id a name jako name. Žádné ACL to vlastně není, jen zbytečný kód navíc.

Pragmatické kritérium: ptejte se, jestli cizí systém používá jiné koncepty, nebo jen jiný formát. Jestli jiné koncepty, tak ACL má smysl. Když jenom jiný formát, tak vám možná stačí obyčejný deserializer.

ACL funguje oběma směry

Většina článků o anti-corruption layer popisuje tok dat do aplikace. Data přijdou z ERP, přeložíme je na doménové objekty a hotovo.

Ale integrace má (nebo spíš může mít) i opačný směr.

Když doménová událost OrderConfirmed musí spustit workflow v ERP, nemůžete poslat doménový event přímo. ERP nerozumí vašemu CustomerId ani ProductId – zná jen svoje ODB_CISLO a MAT_CISLO. Potřebujete překlad i tady.

<?php
// Infrastructure/Erp/Translator/OrderConfirmedTranslator.php
final class OrderConfirmedTranslator
{
    public function toErpOrder(OrderConfirmed $event): ErpOrderDTO
    {
        // Přeložíme doménový event do ERP formátu
        return new ErpOrderDTO(
            TYP_DOKLADU: 'OBJ',
            ODB_CISLO:   $event->customerId->toString(), // náš ID -> ERP číslo odběratele
            MAT_CISLO:   $event->productId->toString(),  // náš productId -> ERP MAT_CISLO
            MNOZSTVI:    $event->quantity->value(),
        );
    }
}

Pro stručnost tu objednávka nese jednu položku – reálně by OrderConfirmed nesl celou kolekci řádků a ERP doklad by měl hlavičku, řádky a další. Princip překladu je ale stejný.

Doménový event nemá o ERP tušení. Translator stojí uprostřed mezi nimi a v obou směrech.

Tohle se zapomíná, protože tok dat „ven“ bývá méně viditelný. Ale je stejně důležitý. Pokud doménový event unikne přímo do externího systému, máte únik v opačném směru – váš doménový model se stává závislým na tom, co ERP akceptuje. A to je začátek zamoření domény zezdola.

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.