Tartalom
0. fejezet
A probléma: a terv és a kód elválik
Miről szól ez a fejezet
Miért nem elég elolvasni a Domain-Driven Design könyvet, és miért csúszik szét szinte minden nagy rendszerben az, amit terveztünk, és amit megírtunk. A D3I erre a problémára készült.
Egy ismerős történet
Egy új rendszer indulásakor mindenki pontosan tudja, mi mire való. Van egy szép architektúra-ábra, van egy wikioldal a fogalmakról, és a csapat ugyanazt érti „rendelés” és „vevő” alatt. Két év múlva az ábra már nem igaz. A wikit senki nem frissíti. A „rendelés” három helyen háromféle mezőkészlettel él, és a kódból csak az tudja visszafejteni az üzleti szabályokat, aki ott volt az elején.
Ezt szokás a fejlesztők lustaságára fogni, pedig az oka szerkezeti. A terv dokumentumokban él, a megvalósítás a forráskódban. A kettő között kézi átírás van, és ami kézzel szinkronizálódik, az előbb-utóbb elcsúszik. Az elcsúszás ráadásul láthatatlan, amíg egyszer bajt nem okoz.
A szokásos válasz az, hogy dokumentáljunk a kódból. Ez segít, de a kódból generált dokumentáció technikai: osztályokról és metódusokról szól, nem vevőkről és rendelésekről. Egy üzleti elemző vagy egy domain szakértő nem tud vele mit kezdeni.
Fordítsuk meg az irányt
A D3I mögött egy egyszerű gondolat áll: ne a kódból készüljön a leírás, hanem a leírásból a kód. A rendszert egy erre kitalált nyelven írjuk le, üzleti fogalmakkal: domain, context, aggregátum, esemény, szerződés. Ebből a leírásból készül a backend váza, a szolgáltatások közti szerződések, a kliensoldali típusok és a gépi feldolgozásra szánt leírás.
Így a modell nem utólagos magyarázat, a rendszer belőle készül. Ha a modell változik, a generált kód is változik. Ha a kézzel írt üzleti logika már nem illik az új modellhez, a projekt nem fordul le. A szétcsúszás így a következő buildnél kiderül, nem két év múlva.
← az ábra oldalra görgethető →
Miért éppen a DDD
Eric Evans Domain-Driven Design című könyve 2003-ban adott nevet annak, amit a jó tervezők addig is ösztönösen csináltak. A szoftver minőségét hosszú távon nem a technológia dönti el, hanem az, mennyire érti és mennyire hűen képezi le az üzleti területet. Evans bevezette a közös nyelvet (ubiquitous language), amelyet az üzlet és a fejlesztés egyaránt beszél, és az építőelemeket: bounded context, aggregátum, entitás, value object, esemény.
A DDD-t elméletben könnyű elfogadni, a gyakorlatban nehéz betartani: egy csapaton belül, évekig, cserélődő emberekkel szinte lehetetlen következetesen alkalmazni. A szabályok kommentekben és code review-kon élnek, és egy fáradt pénteki délutánon átcsúszik egy kivétel. A D3I ezeket a szabályokat a nyelvbe teszi. Ami a nyelvben van, azt a fordító ellenőrzi, nem a reviewer.
1. fejezet
Mi a D3I, és hol a helye
Miről szól ez a fejezet
A D3I részei: a nyelv, az értelmező, a linterek, az emitterek és a dekorátorok. Megnézzük, hogyan lesz egy .d3 fájlból kód, és mi történik, ha a modell hibás.
Egy nyelv és egy fordító
A D3I neve a DDD Interpreter rövidítése. Két dolgot jelent egyszerre. Egyrészt egy saját, domain-specifikus nyelvet, amelynek kulcsszavai a DDD fogalmai: domain, context, aggregate, entity, valueobject, event, interface, workflow. Ezek nyelvtani elemek, nem annotációk vagy elnevezési konvenciók. A fordító tudja, mi a különbség egy aggregátum és egy value object között, mert a grammatika mondja meg.
Másrészt egy Python nyelvű értelmezőt, amely a nyelvet feldolgozza. Fejlesztési időben fut, mint egy fordító: könyvtárként nem kerül be a programodba, és futás közben sem irányít semmit.
A MicronIQ része
A D3I egy nagyobb egész, a MicronIQ keretrendszer első lépcsője. A MicronIQ felhős backendek építésére készült, ahol egyszerre több ezer felhasználó és esemény jelenik meg, és a rendszernek évekig kell karbantarthatónak maradnia. Az elemei egymásra épülnek:
- D3I: a tervezés. Ebből indul minden.
- UniContract: egy interfészdefinícióból több platformra készít konzisztens klienskódot.
- PolyPersist: egységes felület különböző adattárolókhoz, így a domain-logika nem kötődik egy adatbázishoz.
- SrvKit: a szolgáltatások közös alapja: kommunikáció, hibakezelés, hívási környezet, HTTP és gRPC egységesen.
- Orbit: a telepítés. Idempotens deployer, amely a Kubernetes-környezetet, a Helm chartokat és a szolgáltatásokat kezeli.
Ebben az írásban a D3I-ról lesz szó. A többi elemet ott említem, ahol a generált kód rájuk támaszkodik.
Egy futás lépései
Egy futás négy lépésből áll. Az ANTLR 4 alapú elemző beolvassa a .d3 fájlokat, és szintaxisfát épít belőlük. Ebből szemantikus modell lesz, amelyben a nevek már fel vannak oldva: tudjuk, hogy a Customer hivatkozás melyik contextben melyik aggregátumra mutat. A szemantikus modellt linterek ellenőrzik. Végül az emitterek a modellből kimenetet készítenek.
← az ábra oldalra görgethető →
A linterek az emitterek előtt futnak, és ha hibát találnak, a folyamat leáll. Hibás, ellentmondásos modellből nem készül se kód, se dokumentáció. Ez a fail-fast elv: a hiba ott derüljön ki, ahol a legolcsóbb kijavítani.
Linterek, emitterek, dekorátorok
Linterek. Két beépített linter mindig lefut: a szintaktikai és a szemantikai. A szemantikai ellenőrzés a DDD szabályait kényszeríti ki: például hogy interfészen nem jelenhet meg entitás, vagy hogy egy esemény új verziója nem hagyhat el mezőt. Ezek mellé saját lintereket is írhatsz Pythonban, projekt- vagy cégspecifikus szabályokkal.
Emitterek. Az emitter dönti el, milyen kimenet készül. Ma négy van: dotnet:backend (C# szolgáltatásváz), typescript:client (típusok, REST-kliens és validátorok), proto (gRPC-szerződés) és json (a teljes modell gépi formában). Java emitter tervben van. Saját emittert is írhatsz, a mag módosítása nélkül.
Dekorátorok. A dekorátor egy @-cal kezdődő jelölés a modell valamelyik elemén. Új üzleti fogalmat nem vezet be, csak kiegészítő információt ad a lintereknek és az emittereknek. A @public_api(rest, collection="BFF") megmondja, hogy egy interfész milyen technológián és kinek készüljön. Az @optional elhagyhatóvá tesz egy mezőt, a @gdpr személyes adatot jelöl, a @deprecated elavultat. Saját dekorátort is bevezethetsz, és a saját lintered vagy emittered értelmezheti.
A futtatás egyetlen parancs:
python cli.py -p WebShop -i webshop.d3 \
-e dotnet:backend typescript:client proto json \
-o ./generated
A D3I mellé VS Code-bővítmény is tartozik: egy Python nyelvi szerver már írás közben jelzi a hibákat, ugyanazokkal a szabályokkal, mint a fordító.
2. fejezet
A térkép: domain és context
Miről szól ez a fejezet
A modell legfelső két szintje: a domain mint üzleti terület, a context mint felelősségi és telepítési egység. Itt dől el, hány mikroszervizből áll majd a rendszer.
A példa: egy webáruház
A szakma régóta webáruházzal szemlélteti a DDD-t, mert mindenki érti, hogyan működik. Én is ezt használom, de nem kitalált részletekkel: a példák a nyilvános samplewebshop repó valódi modelljéből jönnek. Ez a modell 375 sor, négy fájlban.
Domain: üzleti terület, nem modul
A domain a legmagasabb szintű szervezőelem. Egy alkalmazásban kevés van belőle, és mindegyik egy fő üzleti területet határol el: értékesítés, ügyfélkezelés, fizetés, szállítás, jogosultságkezelés. A domain nem technikai modul. Üzleti csoportosítás: azt mutatja meg, hol vannak az üzleti tudás természetes határai.
Egy kis webshopnál ezek a határok elmosódnak, hiszen egy ember is átlátja az egészet. Egy banknál viszont a hitelezés, a kártyakezelés, a számlavezetés és a befektetés külön világ, külön szakértőkkel és döntéshozókkal. A domainhatár ott ennek az üzleti valóságnak a leképezése. A D3I kis rendszernél is a domainek használatát javasolja. Ez valamivel kötöttebb munkát jelent, de a hibakeresés, a független bővítés és a biztonságos refaktorálás bőven megtéríti.
Context: egy felelősség, egy mikroszerviz
A bounded context a nyelvben egyszerűen context. Ez egy domainen belüli határ, amelyen belül egy modell és egy nyelv érvényes. Egy domainben jellemzően több context van. Ha egy domainben csak egy van, érdemes elgondolkodni, valóban önálló domain-e.
A D3I a contextet egyben mikroszerviz-határnak tekinti. Ami egy contexthez tartozik (aggregátum, service, interfész, eseménykezelő, workflow), az ugyanabba a generált szolgáltatásba kerül. A context így a saját adataival, folyamataival, szerződéseivel és integrációival együtt egy felelősségi egység. Kisebb rendszerben több context közösen is telepíthető, de a modellben a határ akkor is megmarad.
domain Sales {
context OrderManagement {
aggregate Order { ... }
service OrderService { ... }
workflow FulfilOrder { ... }
interface OrderIF version 1 { ... }
interface OrderIF version 2 { ... }
}
context Tracking {
eventhandler onOrderPlaced for integration event OrderManagement.OrderIF#1.OrderPlaced#1
aggregate OrderTrackingEntry { ... }
}
}
← az ábra oldalra görgethető →
A contextek nem hívogatják egymás belsejét. Egymással csak verziózott interfészeken és eseményeken keresztül beszélnek. A Tracking nem tudja, hogyan tárolja az OrderManagement a rendelést, csak azt, amit az OrderManagement magáról kihirdet. Ez elsőre túlzásnak tűnhet egyetlen backendben. De éppen ettől lehet egy contextet úgy átírni, hogy a többi észre se vegye.
Conway törvénye
Melvin Conway 1967-ben figyelte meg, hogy egy rendszer szerkezete a szervezet kommunikációs szerkezetét másolja. Ez a hatás akkor is érvényesül, ha nem tervezünk vele, ezért jobb számolni vele. A D3I számol vele: a domain és a context úgy van kitalálva, hogy természetes csapat- és felelősségi határ legyen. Egy csapat egy contextért felel, és a változásai ott maradnak.
3. fejezet
Az építőkockák
Miről szól ez a fejezet
A contexten belüli elemek: value object, composite, enumeráció, entitás, aggregátum, és az aggregátumok közti hivatkozás. Itt dől el, mi tartozik össze, és hol vannak a konzisztencia határai.
Value object: az érték, aminek nincs személyazonossága
A value objectnek nincs azonosítója. Két cím akkor ugyanaz a cím, ha minden mezőjük megegyezik. A value object nem változik: ha más érték kell, új példány készül. Ez sok állapotkezelési hibát eleve kizár.
A value object lényege, hogy az üzleti fogalom ne essen szét primitív típusokra. Egy postai cím nem négy szöveg, hanem egy cím. Egy pénzösszeg nem egy szám, hanem összeg és pénznem együtt. Ha a modellben így szerepel, nehéz rosszul használni.
valueobject PostalAddress {
country:string validate len(value) == 2
postalCode:string validate len(value) > 0
city:string validate len(value) > 0
street:string validate len(value) > 0
}
A validate egy kis kifejezésnyelv a mezők üzleti szabályaira. Ismeri az összehasonlítást, a logikai műveleteket, a tartomány- és halmazvizsgálatot, és néhány függvényt, például a len-t és a matches-t. Hivatkozhat a mező saját értékére és a testvérmezőkre is, így egy kezdő- és záródátum viszonya is kifejezhető. A D3I típusosan ellenőrzi: szövegre nem lehet „nagyobb, mint”-et írni. A szabályból olvasható ellenőrző kód lesz a szerveren, és ahol értelmes, a kliensoldali típusok mellé is generálódik validátor. A kliens ellenőrzése a felhasználói élményt szolgálja, a szerver mindig újra ellenőriz.
Composite és enumeráció
A composite újrahasznosítható, nevesített mezőcsoport. Önmagában nem üzleti fogalom, és nincs identitása. Entitások és value objectek öröklik, a mezői pedig beépülnek a befogadó típusba. A minta webáruházban minden tárolt entitás a Core domain BaseEntity mezőcsoportját örökli:
composite BaseEntity {
@partitionKey
@system_field
id:string
@system_field
etag:string
@system_field
LastUpdate:datetime
}
Az @system_field azt jelzi, hogy ezek a mezők infrastruktúrához tartoznak, nem az üzlethez. A PolyPersistnek szüksége van rájuk, de az emitter kihagyja őket az üzleti felületről.
Az enumeráció zárt értékkészlet: egy rendelés állapota Draft, Released, Finished vagy Canceled, és semmi más. Kiszorítja a „varázsszámokat” és a szétszórt konstansokat. Ha egy enum a kommunikációban is szerepel, a D3I a .proto definíciót és a leképezést is legenerálja hozzá.
Entitás: azonosító és partíciós kulcs
Az entitásnak azonosítója van, és az állapota az élete során változik. Akkor is ugyanaz az ügyfél, ha új címre költözött. Az entitásoknak gyakran partíciós kulcsuk is van, és ez két különböző dolog. Az azonosító megmondja, melyik entitásról van szó. A partíciós kulcs azt, hogy hová tartozik: melyik logikai vagy fizikai csoportba kerül a tárolóban. Egy rendelésnél ez gyakran a vevő azonosítója, egy készletnél a raktáré.
Ez nagy terhelésnél sokat számít, mert az összetartozó adatok együtt kérdezhetők le és együtt skálázhatók. A D3I-ban ez a döntés a modellben látszik, nem az implementáció mélyén: az @partitionKey ott van a mező fölött.
Aggregátum: a konzisztencia határa
Az aggregátum összetartozó entitások és value objectek egysége, amelyet üzletileg mindig együtt kell kezelni. Egyetlen belépési pontja van, az aggregátum gyökere (root entity). Minden módosítás ezen keresztül történik, ezért a gyökér tudja érvényesíteni az üzleti szabályokat. Például azt, hogy egy rendelés végösszege nem lehet negatív, vagy hogy lezárt rendeléshez nem lehet tételt adni.
aggregate Order {
enum OrderStatuses { Draft, Released, Finished, Canceled }
valueobject OrderItem {
productId:string
productName:string
quantity:number validate value > 0
unitPrice:number validate value >= 0
subTotalPrice:number validate value >= 0
}
root entity OrderHeader inherits Core.Base.BaseEntity, SalesDocument {
# Másik aggregátumra csak azonosítóval hivatkozunk, sosem ágyazzuk be.
customer:ref CustomerManagement.Customers.Customer
orderingDate:date
status:OrderStatuses
totalPrice:number validate value >= 0
items:list[OrderItem]
command place( customerId:string ) emits OrderPlaced
}
}
Az aggregátum mérete tervezési döntés. A túl nagy aggregátum lassú és nehezen skálázható. A túl kicsi nem tudja megvédeni az üzleti szabályokat. A D3I nem dönti el helyetted, de kikényszeríti, hogy a modellben döntsd el, még a kód előtt.
Hivatkozás aggregátumok között: ref
Egy aggregátum nem tartalmazhat másik aggregátumot. Ha a rendelésbe beágyaznánk a vevőt, a két aggregátum élete összekeveredne: ki felel a vevő adataiért, és mi történik, ha a vevő a rendelés mentése közben változik? Ehelyett a rendelés a vevőre azonosítóval hivatkozik, és ezt a ref kulcsszó fejezi ki.
← az ábra oldalra görgethető →
A ref több dolgot ad egyszerre. Kimondja a szándékot. A linter ellenőrizni tudja, hogy valóban létező aggregátumra mutat, és hogy value objectre vagy aggregátumon belüli entitásra ne mutasson. A generált kódban pedig a hivatkozásból nem nyers szöveg lesz, hanem típusos azonosító.
// generált C#
public EntityId<CustomerManagement.Customers.Customer.CustomerAccount> customer { get; set; }
Egy vevő azonosítója és egy rendelés azonosítója ettől típusszinten különbözik. Ha valaki véletlenül rendelésazonosítót ad át ott, ahol vevőazonosító kell, az már fordításkor kiderül, nem élesben, hajnalban. A hálózaton, a gRPC- és REST-szerződésekben a hivatkozás egyszerű szöveges azonosítóként utazik.
4. fejezet
A határ: service, interface, DTO
Miről szól ez a fejezet
A context belső nyelve és a kifelé ígért szerződés két különböző dolog. A D3I ezt a határt szigorúan őrzi: a domain modell nem szivároghat ki.
Service: a belső nyelv
A service azokat az üzleti műveleteket hordozza, amelyek nem illenek egyetlen entitáshoz sem: több aggregátumot érintenek, vagy folyamatot vezérelnek. A service mindig egy context része, és csak azon belül érhető el. Egy mikroszervizben több service is lehet, mindegyik egy jól körülhatárolt feladattal.
A service felületén domain elemek vannak: entitás, value object, aggregátum, enum, primitív típus. DTO nincs. A service a domain nyelvén beszél, technikai engedmények nélkül.
service OrderService {
query getOrder( orderId:string ) : Order.OrderHeader
command placeOrder( order:Order.OrderHeader ) : Order.OrderHeader
}
A command megváltoztatja az állapotot, a query nem. Ez az alapja annak, hogy az olvasási és az írási oldal külön optimalizálható legyen (CQRS). Ebben a view, az olvasásra szabott vetület segít, amely több forrásból is összegyűjthet adatot anélkül, hogy az aggregátumok határát sértené.
Interface: a szerződés
Az interfész a context kifelé látható határa: ezen át éri el más context, más domain, a felhasználói felület vagy egy külső rendszer. Három szabály vonatkozik rá, és mindhármat a linter ellenőrzi:
- Kötelező a verzió. Az interfész ígéret a fogyasztóinak, és a verzió mondja meg, mit ígér.
- A felületén csak DTO, enum és primitív típus lehet. Entitás, value object, aggregátum soha.
- DTO csak interfészen belül definiálható. A DTO kommunikációs adatszerkezet, nem a domain része.
@internal_api(grpc, rest)
@public_api(grpc, rest, collection="BFF")
interface OrderIF version 1 {
dto OrderItemDTO {
productId:string
productName:string
quantity:number validate value > 0
unitPrice:number validate value >= 0
subTotalPrice:number
}
dto OrderDTO {
dto CustomerDataDTO {
customerId:string
customerName:string
}
@system_field id:string
orderingDate:string
orderStatus:OrderStatuses
totalPrice:number
customerData:CustomerDataDTO
items:list[OrderItemDTO]
}
query getOrder( orderId:string ) : OrderDTO
command placeOrder( order:OrderDTO ) : OrderDTO
}
A dekorátorok mondják meg, hogyan érhető el az interfész: belül gRPC-n és REST-en, kívül a BFF-gyűjteményben is. Ugyanabból a definícióból készül a kontroller, a .proto, a belső kliens és a frontend TypeScript-kliense.
Miért nem elég az entitást kiküldeni
Kényelmes volna az OrderHeader entitást egyszerűen visszaadni az interfészen. Pont ez az a kényelem, amely két év múlva megköti a kezed. Ha a belső modell egyben a publikus szerződés, akkor a belső modell nem változhat anélkül, hogy egy külső fogyasztó el ne törjön. A DTO ezt a csatolást vágja el. Azt tartalmazza, amire az adott hívásnak szüksége van. Ugyanahhoz az aggregátumhoz több DTO is tartozhat: egy rövid összesítő a listához, egy részletes a szerkesztőoldalhoz.
Ha valaki mégis entitást tenne az interfészre, a d3i megállítja. Ez a linter valódi üzenete:
Sales.d3(143,47): Severity.Error : An interface may only expose dto, enum and
primitive types on its surface; 'Order.OrderHeader' is a entity.
Map it to a dto for the wire contract.
abort on error is enabled, process is aborted
← az ábra oldalra görgethető →
Repository és ACL
A repository a perzisztencia és a domain határa. Aggregátumot ment és tölt be, mindig a gyökéren keresztül, és csak a saját contextjén belül érhető el. A felületén csak domain elemek vannak. Az implementációja a PolyPersistre épül, így a domain nem tudja, és nem is kell tudnia, hogy SQL-be, dokumentumtárba vagy eseménytárba ment. Mivel interfész mögött van, a tesztekben egyszerűen helyettesíthető.
Az anti-corruption layer (ACL) a külső rendszerek felé véd. Ha a webshop egy futárcég rendszeréhez kapcsolódik, annak státuszkódjai és fogalmai nem kerülhetnek be közvetlenül a saját Shipping domainünkbe. Az ACL lefordítja őket a saját nyelvünkre. Üzleti logika nincs benne, csak fordítás. Egyetlen helyen enged meg olyat, amit máshol a D3I tilt: az any típust, mert itt találkozunk még nem tipizált, idegen adattal.
5. fejezet
Események
Miről szól ez a fejezet
Az esemény múltbeli tény, nem parancs. A D3I megkülönbözteti a belső domain eseményt, a publikus integrációs eseményt és az audit rekordot, mert ezek más-más problémát oldanak meg.
Tény, nem kérés
Egy RPC-hívásnak címzettje van, és választ vár. Az eseménynek nincs címzettje, és nem vár semmit. Azt mondja, hogy „ez megtörtént”, és aki érdekelt, reagál rá. A kibocsátó nem tudja, ki hallgatja, és nem is kell tudnia. Ettől lazul a csatolás: új fogyasztó jöhet anélkül, hogy a kibocsátóhoz hozzányúlnánk.
Az esemény szerkezete szigorú. Csak primitív mezőket és enumot tartalmazhat, entitást vagy value objectet nem, és nincs benne viselkedés.
Ki jegyzi fel, ki küldi el
A minta webáruházban a rendelés gyökere deklarálja, hogy a place parancs eseményt termel:
command place( customerId:string ) emits OrderPlaced
A szerepek pontosan el vannak választva. A gyökér feljegyzi a tényt, mert ő érvényesíti azt a szabályt, amelytől a tény igaz. A repository a mentéssel együtt egy outboxba teszi, ugyanabban a tranzakcióban. A kézbesítés a platform dolga. Így nem fordulhat elő, hogy a rendelés elmentődik, de az eseményt valami miatt senki nem kapja meg, vagy fordítva. A generált kód ezt meg is magyarázza:
/// Writes down that 'OrderPlaced' happened. It is NOT sent here: the repository moves it
/// into the outbox inside the same save, and delivery is the platform's problem from there.
protected void Record( OrderPlaced @event )
=> _recordedEvents.Add( new RecordedEvent( @event, id ) );
Belső tény és publikus szerződés
A domain esemény a context belső nyelvén szól. Nincs verziója, és ez szándékos: a fogyasztói ugyanabban a telepítési egységben vannak, együtt változnak vele, ezért ha a szerkezete eltörik, abból fordítási hiba lesz, nem éles incidens.
domain event OrderPlaced {
orderId:string
customerId:string
totalPrice:number
}
Az integrációs esemény ugyanannak a ténynek a publikus megfogalmazása, egy interfészen belül, verzióval. A from megmondja, melyik belső tényből készül. A fordítást szándékosan kézzel kell megírni, mert a publikus nyelv nem azonos a belsővel. Itt például a customerId bent marad, az összeg pedig úgy kap nevet, ahogy a külvilág hívja.
interface OrderIF version 1 {
integration event OrderPlaced version 1 from Order.OrderPlaced {
orderId:string
totalAmount:number
}
...
}
A fogyasztó a publikus eseményre iratkozik fel, verzióra pontosan. A Tracking context így reagál a megrendelésre:
context Tracking {
eventhandler onOrderPlaced for integration event OrderManagement.OrderIF#1.OrderPlaced#1
...
}
Ettől holnap az OrderManagement átnevezhet egy belső mezőt, és a Tracking észre sem veszi. A kezelő a contexthez tartozik, nem egy service-hez, mert a context az, ami reagál. Hogy melyik osztály futtatja, az már megvalósítási részlet.
← az ábra oldalra görgethető →
Audit: bizonyíték, nem esemény
A harmadik fajta a bejelentkezés, a jelszócsere, egy jogosultság módosítása: ki mit csinált és mikor. A korábbi terveimben ez is eseményfajta volt. A nyelvben végül audit record lett, mert az audit nem esemény. Senki nem reagál rá, nem indít folyamatot, csak megőrizzük bizonyítéknak. Ha eseménynek hívnánk, olyan viselkedést ígérnénk, amely nincs. Az ilyen apró névadási döntéseken múlik, hogy a modell évek múlva is igazat mond-e.
Tároláskor viszont mindkettő ugyanaz: megváltoztathatatlan, csak hozzáfűzhető bejegyzések időrendben. Ezért az üzleti eseményfolyam és az audit ugyanabban az eseménytárban él, külön audit-adatbázis nélkül. Ha egy aggregátum eventsourced jelölést kap, az aktuális állapota nem is tárolódik közvetlenül. Az eseményeiből áll össze, és bármikor visszajátszható.
6. fejezet
Verziózás: amit egyszer megígértél
Miről szól ez a fejezet
Egy publikus interfész vagy esemény ígéret. A D3I megköveteli a verziószámot, és azt is ellenőrzi, hogy az új verzió betartja-e, amit a régi ígért.
Két verzió egymás mellett
Az eseményvezérelt rendszerek egyik legrosszabb hibája a csendes törés. A kibocsátó megváltoztatja egy üzenet szerkezetét, a fogyasztó nem tud róla, és hibás adatot dolgoz fel, hibaüzenet nélkül. A legtöbb helyen ezt wikioldal és jó szándék kezeli.
A D3I-ban az interfész új verziója az előző mellé kerül, nem a helyére. A minta webáruházban az OrderIF version 1 és version 2 egyszerre él. A régi fogyasztók maradhatnak a v1-en, az újak átállhatnak a v2-re. A generált kódban mindkettőnek saját osztálya, kontrollere és .proto fájlja van: OrderIF_v1, OrderIF_v2.
A deklaráció olvasható alakot használ (version 2), a hivatkozás tömöret: OrderIF#1.OrderPlaced#1. A hivatkozásban így mindig látszik, melyik verzióra számítasz.
A verziószám ígéret
Erre a minta webáruház fejlesztése közben volt egy példa. A v2 integrációs esemény első változata el akarta hagyni a totalAmount mezőt, és helyette a customerId-t publikálta volna. Az evolúciós linter visszadobta:
Sales.d3(154,12): Severity.Error : 'OrderPlaced' version 2 drops the field
'totalAmount', which version 1 has. A higher version is the same fact restated,
so what it used to say has to survive; if the field really is gone, this is a
different fact and deserves its own name.
A szabály egyszerű. Egy magasabb verzió ugyanannak a ténynek az újrafogalmazása, ezért többet mondhat, de nem mondhat kevesebbet. Aki v1-ről v2-re vált, nem veszíthet el semmit, amit eddig megkapott. Ha egy mező tényleg megszűnik, akkor az már egy másik tény, és másik nevet érdemel.
← az ábra oldalra görgethető →
Végül a v2 mindkét mezőt megtartotta, és hozzátette a harmadikat. Ugyanabból a belső Order.OrderPlaced tényből így két publikus szerződés készül, két külön kézzel írt fordítással. Ez a gyakorlatban azt jelenti, hogy egy interfészt úgy viszünk tovább, hogy a régi hívókat nem törjük el.
7. fejezet
Hosszú folyamatok: workflow
Miről szól ez a fejezet
Vannak üzleti folyamatok, amelyek nem férnek bele egy tranzakcióba: napokig tartanak, félúton elbukhatnak, és vissza kell csinálni, amit már megcsináltak. A D3I ezeket elsőrendű elemként, workflow-ként kezeli.
Amikor egy függvény már nem elég
Egy kezdő webshopban a rendelés lezárása egy státuszfrissítés. Aztán bejön a készletfoglalás. Aztán a kártyás fizetés, a számlázás, a szállítás előkészítése, egy külső futárcég értesítése. Ezek nem egyszerre történnek, és bármelyik elbukhat. Workflow nélkül két rossz út marad. Az egyik, hogy az egész egyetlen, egyre hízó service-függvénybe kerül. A másik, hogy események laza láncává esik szét, amelyet senki nem lát egyben. Mindkettő ott bukik meg, ahol egy lépés félúton elhasal, és vissza kell csinálni a korábbiakat.
A felhasználói regisztráció ugyanez kicsiben: e-mail-ellenőrzés, aktiváló link, jelszóbeállítás, alapjogosultságok. A lépések között órák telhetnek el, és a folyamatnak pontosan onnan kell folytatódnia, ahol abbamaradt.
Lépések és kompenzációk
A D3I workflow-ja leírja, milyen lépések vannak, és melyiket mi teszi vissza. A sorrendet szándékosan nem a modell írja le, mert egy modell becsületesen csak a lépések halmazát tudja kimondani. A sorrendet a fejlesztő írja meg. A generált váz minden végrehajtott lépés kompenzációját feljegyzi, és hiba esetén fordított sorrendben lefuttatja őket.
@retry( 3 )
@timeout( "10m" )
workflow FulfilOrder {
@start
command fulfil( order:ref Order, sku:string, quantity:number,
amount:number, shippingAddress:string ) : string
# Nincs visszatérési értéke, tehát jelzés: a hívó nem kap választ.
command cancel( reason:string )
# A lekérdezés csak olvas, és szinkron: nem várhat semmire.
query status() : string
step reserveStock( order:ref Order, sku:string, quantity:number ) : string compensate releaseStock
step releaseStock( order:ref Order, sku:string, reservationId:string )
# A kártyaterhelés nem idempotens: egyetlen próbálkozás, rövid határidő.
@retry( 1 )
@timeout( "30s" )
step chargeCard( order:ref Order, amount:number ) : string compensate refundCard
step refundCard( order:ref Order, chargeId:string )
# A szállítást semmi nem teszi vissza: ami elment, az elment.
step shipOrder( order:ref Order, shippingAddress:string ) : string
}
Ebben a pár sorban sok üzleti döntés van, és mind a modellben látszik. A kártyaterhelés nem idempotens, ezért csak egyszer próbáljuk, és rövid a határideje, hogy a pénz ne ragadjon bizonytalan állapotban. A szállításnak nincs kompenzációja, és ez a hiány maga is állítás. Ezek nem technikai beállítások, hanem üzleti tulajdonságok, ezért a modellbe valók.
← az ábra oldalra görgethető →
Ami a háttérben fut
A workflow mögött a Temporal fut, amely a hosszú folyamatok állapotát tartósan tárolja, újraindítás után folytatja, és látni engedi, hol tart egy folyamat. A D3I a lépésekből Temporal-aktivitásokat generál. Ezek interfészét kell megvalósítani:
public partial interface IFulfilOrderActivities
{
[Activity]
Task<string> reserveStock(EntityId<Order.OrderHeader> order, string sku, decimal quantity);
[Activity]
Task releaseStock(EntityId<Order.OrderHeader> order, string sku, string reservationId);
[Activity]
Task<string> chargeCard(EntityId<Order.OrderHeader> order, decimal amount);
...
}
A workflow-k hatókör szerint is elkülönülnek. Van, amelyik egy contexten belül marad, van, amelyik egy domainen belül több contextet hangol össze, és van, amelyik domaineken ível át. Mindegyik önálló szolgáltatásként fut a Kubernetesben, így a skálázása és a figyelése a saját terheléséhez igazodik.
8. fejezet
Mit kapsz a generátortól
Miről szól ez a fejezet
A minta webáruház 375 sor modell. Ebből 54 fájl és nagyjából hatezer sor kód lesz. Megnézzük, mi az, amit a d3i megír, mi az, amit neked kell, és hogyan tartja a kettőt egyben a fordító.
A generált szolgáltatás felépítése
Minden contextből egy egységes felépítésű mikroszerviz lesz. Ha egyszer kiismerted az egyiket, mindegyikben otthon vagy:
- Interfaces: a publikus, verziózott szerződések, a DTO-k, a gRPC-leképezés és a
.protofájlok. - Context: a context magja. A belső service- és ACL-interfészek, a gRPC- és REST-kontrollerek, az eseménykezelők és a workflow-k vázai, valamint az implementációk, ahol az érdemi munka folyik.
- InternalClient: típusos kliensek, amelyekkel más contextek hívhatják ezt a szolgáltatást a rendszeren belül. A kliens szolgáltatást kér, nem címet.
- Models: az entitások, value objectek és enumok generált kódja. Kézzel nem módosítjuk, mert újragenerálódik.
- Services: az indításhoz, konfigurációhoz és telepítéshez szükséges elemek, a SrvKitre építve.
A generált kód és a kézzel írt kód határa
A legfontosabb tervezési döntés az, hogy hol a határ. A d3i minden olyan kódot megír, amely a modellből egyértelműen következik. Te azt írod, ami üzleti döntés. A kettőt C#-ban partial osztályok és metódusok kötik össze: a generált fél deklarálja, mi kell, a te feled adja a törzset.
/// Declared by the model; the body is yours. Without it this does not compile - it does
/// not quietly become a command that is merely never there.
public partial void place(string customerId);
Ezt nevezem biztonsági hálónak. Ha a modellbe új parancs, új eseménykezelő vagy új fordítás kerül, és nem írod meg a törzsét, a projekt nem fordul le, vagyis a hiányzó kód nem maradhat észrevétlen. Ugyanez fordítva: ha a modellből kikerül valami, a hozzá írt kódod hibát jelez. A modell és a kód nem csúszhat szét, mert a fordító a kettő közti szerződést ellenőrzi.
← az ábra oldalra görgethető →
Néhány generált részlet
Egy value object a modellben öt sor. A generált C# osztály tartalmazza az értékalapú működéshez szükséges klónozást, összehasonlítást és hash-számítást, és a validate szabályokból olvasható ellenőrzést:
public virtual void ValidateInto( IList<IValidationError> errors, string pathPrefix )
{
if (quantity <= 0)
errors.Add( new ValidationError { TypeOfEntity = "OrderItem", MemberOfEntity = "quantity",
Path = pathPrefix + "quantity", ErrorText = "quantity must satisfy: value > 0" } );
if (unitPrice < 0)
errors.Add( new ValidationError { TypeOfEntity = "OrderItem", MemberOfEntity = "unitPrice",
Path = pathPrefix + "unitPrice", ErrorText = "unitPrice must satisfy: value >= 0" } );
}
A hibának útvonala van (items[1].quantity), ezért egy űrlap a megfelelő mezőhöz tudja kötni, nem kell szövegből kitalálnia. Ugyanez a szabály a frontend TypeScript-kódjában is megjelenik:
export function validateOrderItemDTOInto( dto: OrderItemDTO, pathPrefix: string,
errors: ValidationError[] ): void {
if (Number(dto.quantity) <= 0)
errors.push({ typeOfEntity: "OrderItemDTO", memberOfEntity: "quantity",
path: pathPrefix + "quantity", errorText: "quantity must satisfy: value > 0" });
...
}
Az interfészből gRPC-szerződés is készül, egységes válaszborítékkal: státusz, hibalista és az eredmény.
service OrderIF_v1 {
rpc getOrder(OrderIF_v1_getOrderRequest) returns (OrderIF_v1_getOrderResponse);
rpc placeOrder(OrderIF_v1_placeOrderRequest) returns (OrderIF_v1_placeOrderResponse);
...
}
message OrderIF_v1_getOrderResponse {
ServiceKit.Protos.Statuses Status = 1;
repeated ServiceKit.Protos.Error Errors = 2;
oneof result {
OrderDTO Value = 3;
}
}
A modellben írt # kezdetű dokumentációs sorok végigmennek a generált kódon. Ami a modellben magyarázat volt, az a C#-ban XML-dokumentáció, a .proto-ban megjegyzés lesz. A leírás és a kód itt sem válik el.
Tesztelhetőség
A generált struktúra interfészekre és dependency injectionre épül. A service-ek, repositoryk és külső kapcsolatok mind interfész mögött vannak, így az üzleti logika izoláltan tesztelhető, adatbázis és hálózat nélkül. A tesztelhetőséget így nem kell utólag beépíteni.
9. fejezet
A háttérben álló gondolkodás
Miről szól ez a fejezet
Miért mikroszervizek, miért események, miért ennyiféle tároló? Ez a fejezet azt a tapasztalatot foglalja össze, amelyből a D3I szabályai származnak.
Monolit, mikroszerviz, esemény
Sokáig a monolit volt az alapértelmezés: egy futtatható egységben az üzleti logika, az adattárolás, az integrációk és a felület. Kis léptékben egyszerű és jó. Ahogy nő, úgy nő a változtatás kockázata, a skálázás csak egészben lehetséges, és az üzleti területek határai elmosódnak.
A mikroszerviz erre adott válasz: kisebb, önállóan telepíthető egységek, világosabb felelősség, célzott skálázás, párhuzamosan dolgozó csapatok. A komplexitás azonban nem tűnt el, csak átköltözött a szolgáltatások közé: kommunikáció, állapot, folyamatok összehangolása. Erre jött az eseményvezérelt szemlélet, amely a hívások helyett a történésekre figyel, és ebből nőtt ki az event sourcing is.
A D3I ezt az utat követi, és nem enged visszacsúszni egy rejtett monolitba. A contextek mikroszerviz-határok, az együttműködés fő eszköze az esemény, a hosszú folyamatok workflow-k. Ez nem jelenti azt, hogy minden rendszernek maximális bonyolultsággal kell indulnia. Azt jelenti, hogy a szerkezet eleve alkalmas a növekedésre.
Minden adat a megfelelő tárolóba
Csábító egyszerűsítés, hogy „minden megy az SQL-be”. Kis rendszerben működik is. Nagyobb rendszerben az adatok annyira különböző módon keletkeznek, változnak és olvasódnak, hogy egy tároló mindegyiknek csak rosszul tud megfelelni.
← az ábra oldalra görgethető →
- Relációs adatbázis oda, ahol erős konzisztencia és tranzakció kell, és mindig az aktuális állapot számít: rendelési állapot, készlet, pénzügyi nyilvántartás.
- Dokumentumtár oda, ahol az adat természetes egységet alkot és együtt mozog: egy termék teljes leírása, attribútumai, fordításai.
- Keresőmotor a gyors, rugalmas kereséshez és szűréshez. Ez nem elsődleges tároló, hanem a lekérdezés kiszolgálója, és leveszi a terhet az operatív adatbázisról.
- Széles oszlopos tároló a nagy, folyamatosan hozzáfűzött adatokhoz, partíció szerinti eléréssel. Az eseménytár egyik lehetséges háttere.
- Eseménytár a megváltoztathatatlan, időrendi eseményekhez. Ebből bármikor visszafejthető egy rendelés teljes élete, és ugyanez szolgálja ki az auditot.
- Analitikai tároló a riportokhoz és statisztikákhoz, feldolgozott eseményekből.
- Cache a gyakran olvasott, ritkán változó adatokhoz. Teljesítményeszköz, nem forrás.
Ez a sokféleség csak akkor kezelhető, ha a domain nem tud róla. Ezt adja a PolyPersist: a repository egységes felületen ment és tölt be, a tároló kiválasztása pedig a domainen kívüli döntés marad.
Backend a felület szolgálatában
A Backend for Frontend (BFF) minta szerint a felhasználói felületeket nem a domain-szolgáltatások interfészei szolgálják ki közvetlenül, hanem egy kifejezetten az adott felülethez szabott backend réteg. A webes felület, a mobilalkalmazás és a belső adminfelület más adatot, más szerkezetben, más válaszidővel igényel. Ha egyetlen interfész próbálja mindet kiszolgálni, túlterhelt és nehezen verziózható API lesz belőle.
A D3I-ban a BFF ugyanolyan verziózott interfész, mint a többi, csak a dekorátora más: @public_api(rest, collection="BFF"). Ebből a d3i a felületnek szóló TypeScript-klienst és a .NET API-klienst is legenerálja. A domain modell eközben egységes marad.
„Ez túl bonyolult”
A fenti minták első ránézésre sokszor túltervezettnek tűnnek. Ennek megvan az oka: valós, nagy léptékű rendszerek problémáira adnak választ, és amíg egy probléma nem jelentkezik, a megoldása sem tűnik fontosnak. Amikor a rendszer mérete, terhelése vagy üzleti bonyolultsága elér egy szintet, ezekből a döntésekből alapfeltétel lesz. Akkor viszont már késő újratervezni.
A D3I azt ígéri, hogy ezeket a döntéseket egy átlagos, medior csapat is következetesen meg tudja hozni az első naptól, anélkül hogy minden tagja végigjárta volna azokat a drága, elhúzódó projekteket, amelyekből ez a tapasztalat származik. A szabályok a nyelvben vannak, nem a fejekben.
10. fejezet
Mikor érdemes, mikor nem, és hol tart a projekt
Miről szól ez a fejezet
Kinek való a D3I, kinek nem, mi működik már, és mi van még hátra.
Érdemes, ha…
- a domain összetett: sok context, aggregátum és üzleti szabály, és ezek viszonya számít;
- több fejlesztő dolgozik rajta, és a modell szinkronban tartása valódi gond;
- a rendszernek egyszerre kell backend-szolgáltatásként, gRPC-szerződésként és frontend-kliensként megjelennie;
- a skálázhatóság és az eseményvezérelt működés eleve követelmény, nem utólagos igény;
- az üzleti modell várhatóan sokat változik, és a változásokat követhetően kell kezelni;
- egy junior–medior csapatnak kell senior szintű architekturális döntéseket következetesen megvalósítania.
Nem érdemes, ha…
- egyszerű, rövid életű alkalmazásról van szó. Egy hétvégi CRUD API-hoz ez ágyúval verébre;
- a csapat nem ismeri a DDD fogalmait. A nyelv formalizálja a DDD-t, de nem tanítja meg;
- csak dokumentálni szeretnéd a domaint, kódgenerálás nélkül. Ehhez egy jó ábra is elég;
- stabil, végleges API-t vársz. A projekt aktív fejlesztés alatt áll, a korai átvevőknek számolniuk kell törésekkel.
Hol tart
A D3I nyílt forrású, Apache 2.0 licenccel. Ami ma működik:
- a teljes nyelv: domain, context, aggregátum, entitás, value object, composite, enum, view, service, interfész, DTO, repository, ACL, a háromféle tény, eseménykezelő és workflow;
- a
validatekifejezésnyelv típusos ellenőrzéssel, szerver- és kliensoldali kódgenerálással; - a szemantikai linter a DDD-szabályokkal és a verzióevolúció ellenőrzésével;
- a .NET backend, a TypeScript kliens, a Protocol Buffers és a JSON emitter;
- a Temporal-alapú workflow-váz és az outboxos eseménykezelés;
- VS Code-bővítmény nyelvi szerverrel, és több mint 340 automata teszt.
Ami hátravan: a Java emitter, a @gdpr jelölésből generált törlési és exportálási mechanizmus, és a MicronIQ többi elemének (SrvKit, Orbit) nyilvános kiadása.
Kipróbálni
- A forrás: github.com/gyorgy-gulyas/d3i
- A minta webáruház a teljes modellel: github.com/gyorgy-gulyas/samplewebshop
- A kapcsolódó könyvtárak: UniContract és PolyPersist
A D3I-tól nem azt várom, hogy kevesebb kódot kelljen írni. Azt várom tőle, hogy a context-határok, a verzióígéretek és a modellben rögzített döntések betartását a fordító ellenőrizze, és ne a csapat emlékezetén múljon.
Függelék A
Szójegyzék
Magyar kifejezés, angol eredeti, egysoros meghatározás, a magyar kifejezés szerinti betűrendben.
| Magyar | Angol | Meghatározás |
|---|---|---|
| Aggregátum | aggregate | Összetartozó entitások és value objectek egysége, a konzisztencia határa; csak a gyökerén át módosítható. |
| Aggregátumgyökér | aggregate root | Az aggregátum egyetlen belépési pontja (root entity), amely az üzleti szabályokat érvényesíti. |
| Anti-corruption layer | anti-corruption layer, ACL | Fordítóréteg egy külső rendszer és a saját domain között, hogy az idegen fogalmak ne szivárogjanak be. |
| Audit rekord | audit record | Megőrzésre szánt bizonyíték (ki, mit, mikor); nem esemény, mert senki nem reagál rá. |
| Backend for Frontend | BFF | Egy adott felhasználói felülethez szabott backend-interfész. |
| Bounded context | bounded context | Határ, amelyen belül egy modell és egy nyelv érvényes; a D3I-ban egy context egy mikroszerviz. |
| Composite | composite | Újrahasznosítható, nevesített mezőcsoport, amelyet entitások és value objectek örökölnek. |
| CQRS | Command Query Responsibility Segregation | Az állapotot módosító parancsok és a csak olvasó lekérdezések szétválasztása. |
| Dekorátor | decorator | @-cal kezdődő jelölés, amely kiegészítő információt ad a lintereknek és az emittereknek. |
| Domain | domain | Fő üzleti terület (pl. értékesítés, fizetés); üzleti csoportosítás, nem technikai modul. |
| Domain esemény | domain event | Egy context belső, verzió nélküli ténye, amelyet a saját telepítési egységén belül dolgoznak fel. |
| DTO | data transfer object | Kommunikációs adatszerkezet, csak interfészen belül definiálható; nem része a domain modellnek. |
| Emitter | emitter | A d3i modulja, amely a modellből egy adott kimenetet készít (pl. .NET, TypeScript, proto, JSON). |
| Entitás | entity | Azonosítóval rendelkező üzleti objektum, amelynek állapota az élete során változik. |
| Enumeráció | enum | Zárt, előre megadott értékkészlet, például egy rendelés lehetséges állapotai. |
| Eseménykezelő | event handler | A context nevesített reakciója egy eseményre; a D3I-ban az eventhandler kulcsszó. |
| Event sourcing | event sourcing | Az állapot nem közvetlenül tárolódik, hanem az eseményekből áll össze és játszható vissza. |
| Hivatkozás azonosítóval | ref | Aggregátumok közti kapcsolat; a generált kódban erősen típusos azonosító lesz belőle. |
| Integrációs esemény | integration event | Egy belső tény publikus, verziózott megfogalmazása, amelyet más contextek és rendszerek fogyasztanak. |
| Interfész | interface | A context kifelé látható, verziózott szerződése; a felületén csak DTO, enum és primitív lehet. |
| Kompenzáció | compensation | Egy workflow-lépés hatását visszacsináló lépés; hiba esetén fordított sorrendben futnak. |
| Közös nyelv | ubiquitous language | Az üzlet és a fejlesztés által egyaránt használt nyelv, amely a modellben is megjelenik. |
| Linter | linter | Ellenőrző, amely a generálás előtt a modell szintaktikai, szemantikai vagy saját szabályait vizsgálja. |
| Outbox | outbox | Az aggregátummal azonos mentésben tárolt kimenő események listája, ahonnan a platform kézbesít. |
| Partíciós kulcs | partition key | Azt mondja meg, hová tartozik egy entitás a tárolóban; az összetartozó adatok együtt maradnak. |
| Repository | repository | Aggregátumokat mentő és betöltő absztrakció, amely elrejti a tároló technológiáját. |
| Saga | saga | Több lépésből álló, kompenzációkkal visszagörgethető folyamat; a D3I-ban workflow. |
| Service | service | A context belső üzleti műveletei, domain típusokkal; kívülről nem érhető el. |
| Single source of truth | single source of truth | Az egyetlen hiteles forrás; a D3I-ban a .d3 modell. |
| Validálás | validate | A mező mellé írt üzleti szabály, amelyből szerver- és kliensoldali ellenőrző kód készül. |
| Value object | value object | Azonosító nélküli, nem változó érték, amelyet a mezői határoznak meg (pl. cím, összeg). |
| View | view | Olvasásra szabott vetület, amely több forrásból gyűjthet adatot, és nem módosítható. |
| Workflow | workflow | Elhúzódó, állapottal rendelkező üzleti folyamat lépésekkel és kompenzációkkal. |