Event Sourcing v DDD a Symfony
Event Sourcing v kontextu Domain-Driven Design a Symfony – implementace Event Store, event-sourcovaných agregátů, projekcí, Outbox patternu, snapshottingu a verzování událostí. Včetně praktických problémů: idempotence projektorů, rebuild projekcí a eventual consistency.
Obsah kapitoly
13.01 Co je Event Sourcing?
Tradiční CRUD persistence má slepou skvrnu: při každé změně přepíše předchozí stav a veškerá historie se nenávratně ztrácí. Event Sourcing (ES) ukládá stav systému jako sekvenci neměnných událostí, jež k danému stavu vedly [1]. Každá změna stavu domény je zaznamenána jako samostatná, pojmenovaná událost se svými daty. Aktuální stav agregátu pak vzniká přehráním (replay) těchto událostí od počátku.
Princip lze vyjádřit větou: current state is derived from the history of events. Namísto jediného řádku v databázové tabulce, který je při každé změně přepisován, existuje append-only log všech událostí, jež kdy na agregátu nastaly.
Porovnání s tradiční CRUD persistencí
V klasickém CRUD přístupu drží tabulka pouze aktuální stav entity – jakmile se hodnota změní, předchozí je pryč. Event Sourcing zapisuje každou změnu jako nový řádek event logu, takže žádná informace se nikdy nepřepisuje ani nemaže.
13.02 Vztah k CQRS
Event Sourcing a CQRS jsou dva samostatné vzory [2]. Nejsou totéž – lze aplikovat CQRS bez Event Sourcingu a naopak ES bez CQRS. V praxi DDD aplikací se ale obvykle objevují společně.
Důvod je technický: Event Sourcing produkuje události jako základní artefakt persistence a CQRS potřebuje způsob, jak aktualizovat read modely při každé změně write strany. Události tuto propagaci pokrývají bez další infrastruktury – write side uloží událost do Event Store, read side ji přečte a aktualizuje projekci.
13.03 Doménové události jako základ Event Sourcingu
V Event Sourcingu jsou doménové události (Domain Events) zdrojem pravdy o stavu systému – nejen notifikací o vedlejších efektech, jako je tomu u událostí v Doctrine ORM aplikaci. Tomu odpovídají i přísnější požadavky na jejich tvar:
- Immutabilita – Po vytvoření nelze událost měnit. Veškeré její properties jsou read-only, nastavené v konstruktoru.
- Serializovatelnost – Událost musí být serializovatelná do trvalého formátu (JSON, MessagePack…) a deserializovatelná zpět bez ztráty informace.
- Verzování – Schéma události se v čase může vyvíjet. Stará data v Event Store je třeba udržet čitelná, typicky pomocí upcastingu (transformace starých verzí na aktuální).
- Pojmenování v minulém čase – Události vyjadřují fakta, která již nastala:
UserRegistered,OrderPlaced,PaymentFailed. - Dostatečná granularita dat – Událost musí obsahovat veškerá data potřebná k tomu, aby z ní bylo možné rekonstruovat stav, aniž by byl nutný přístup k externím zdrojům.
Konvence pojmenování událostí by měla být konzistentní napříč celým projektem. Doporučený formát pro
eventType() je <bounded_context>.<past_tense_verb_noun>, například
ordering.order_placed nebo payment.payment_received. Tato konvence usnadňuje
routing událostí v Symfony Messenger a jejich filtrování v Event Store.
13.04 Implementace Event Store
Event Store je append-only databázové úložiště pro všechny doménové události. Každý záznam nese jednu událost s jejím kontextem – ke kterému agregátu patří, v jaké verzi streamu a kdy nastala. Záznamy se nikdy nepřepisují ani nemažou.
Struktura tabulky Event Store
Sloupec version nese optimistic locking. Před zápisem nové události command handler
přečte poslední verzi streamu agregátu. Pokud mezitím jiný proces zapsal událost se stejnou
verzí, databáze při insertu vyvolá výjimku z porušení unikátního indexu uq_aggregate_version.
Souběžné zápisy se tak detekují bez pesimistického zamykání řádků.
13.05 Agregát s Event Sourcingem
V klasickém DDD agregát mění svůj stav přímou modifikací vlastních atributů. V Event Sourcingu každá změna stavu prochází přes doménovou událost. Metody agregátu nemodifikují atributy přímo – nahrají událost a teprve její aplikace na stav vyvolá změnu.
Výsledkem je, že agregát obsahuje dvě sady metod:
- Mutační metody (veřejné rozhraní agregátu) – validují invarianty, rozhodují, která událost nastane, a volají interní metodu pro nahrání události (typicky
recordEvent()). apply*()metody (private/protected) – přijmou konkrétní typ události a aplikují změnu na interní stav. Tyto metody jsou volány jak při nahrávání nové události, tak při replay z Event Store.
Pro testování to znamená vzor given/when/then – given jsou historické události, when je volání metody na agregátu, then jsou nově emitované události. Podrobně v kapitole Testování DDD kódu.
Načítání agregátu z event streamu (replay)
Repozitář pro event-sourcovaný agregát neprovádí SELECT do tabulky entit. Místo toho načte
event stream z Event Store a předá jej statické tovární metodě reconstituteFromEvents().
Výsledný agregát má přesně takový stav, jaký odpovídá historii jeho událostí.
13.06 Projekce (Projections)
Event Store je append-only a neumí ad-hoc dotazy typu „všechny objednávky zákazníka X s celkovou hodnotou nad 1000 Kč“. Pro takové dotazy vznikají vedle něj projekce – denormalizované read modely budované z event streamu specificky pro tvar dotazů aplikace.
Synchronní vs. asynchronní projekce
- Synchronní projekce – Projekce se aktualizuje přímo v téže transakci jako zápis události. Garantuje konzistenci dat v okamžiku odpovědi na command, ale zvyšuje latenci zápisu a zavádí těsnou vazbu mezi write a read stranou.
- Asynchronní projekce – Události jsou po uložení do Event Store zařazeny do fronty (Symfony Messenger + transport jako RabbitMQ nebo Redis). Projector je konzument, který zpracovává zprávy nezávisle. Read model je v krátkém časovém okně nekonzistentní (eventual consistency), ale write side je rychlejší a oddělená.
Asynchronní doručování událostí projektorům přes Symfony Messenger vyžaduje nastavený
transport a routing v config/packages/messenger.yaml:
Projekce lze přebudovat (rebuild) přehráním celého Event Store od začátku. Při změně doménových požadavků stačí vytvořit novou projekci a přehrát historii. CRUD systémy tuto možnost nemají – historická data v nich už nejsou k dispozici.
13.07 Outbox a transakční doručování událostí
Předchozí sekce ukazovala projektory jako Messenger handlery, které dostávají doménové události z asynchronní fronty. Implicitně jsme předpokládali, že se událost po zápisu do Event Store spolehlivě dostane do message brokeru. V produkci to bez další infrastruktury neplatí. Zápis do databáze a publikace zprávy do brokeru jsou dvě nezávislé operace a nelze je obalit jedinou transakcí. V literatuře se tento problém označuje jako dual-write problem a řeší ho Outbox pattern [1].
Dual-write problém
Představte si pořadí kroků v save() metodě repozitáře, který nejprve commitne
transakci do Event Store a hned poté volá $bus->dispatch($event):
- Pokud server spadne mezi commitem a dispatchem, událost je v databázi, ale nikdy se nedostane k projektorům či externím konzumentům. Read modely se rozejdou se stavem write strany.
- Pokud naopak provedete dispatch před commitem a transakce se rollbackuje, konzumenti zpracují událost, která se nikdy nestala. Vznikají duplicity, které se obtížně dohledávají.
- Při restartu workeru, dočasné nedostupnosti brokeru nebo síťovém partition se chyba projeví latentně až po hodinách provozu.
Outbox pattern problém přesouvá tam, kde si s ním databáze poradí: událost se zapíše do téže transakce jako doménová změna a samostatný proces (relay) ji následně přečte a publikuje do brokeru. Atomicitu zápisu hlídá databáze, doručení do brokeru zajišťuje relay s mechanismem at-least-once.
Event Store jako outbox
V Event Sourcingu už tabulka event_store sama o sobě splňuje všechny vlastnosti outbox
tabulky. Je append-only, má auto-increment id pro globální uspořádání zápisů a každý záznam
je zapsán ve stejné transakci jako odpovídající doménová změna. Druhá tabulka tedy nepřibývá –
stačí přidat relay worker, který sleduje nové řádky a posílá je do Messengeru.
Worker si pamatuje pozici posledního publikovaného řádku v jednoduché checkpoint tabulce.
Při restartu pokračuje od uloženého id, takže ani opakovaný start nevynechá
ani neduplikuje událost na úrovni publikace (duplicitu na straně konzumentů řeší idempotence
popsaná v následující sekci).
Spuštění relay v produkci řešte buď cron jobem volajícím publishPending()
v krátkém intervalu (např. po sekundě), nebo dlouho běžícím procesem v supervisoru, který
mezi iteracemi spí podobný interval. Lock FOR UPDATE na řádku
outbox_position garantuje, že více souběžných instancí relay nebude publikovat
stejné události paralelně.
Záruky doručení a jejich důsledky
Outbox dává at-least-once doručení uvnitř jednoho kanálu se zachovaným
globálním pořadím podle event_store.id. Konkrétně:
- At-least-once: pokud relay spadne mezi dispatchem a updatem checkpointu, stejná událost se po restartu publikuje znovu. Konzumenti musí být idempotentní – přesně tak, jak ukazuje následující sekce u projektorů.
- Globální pořadí: události jsou publikovány vzestupně podle
id, takže projektor uvidíOrderCreatedpředOrderShipped. Pořadí napříč streamy různých agregátů ale není zajištěno – pokud ho projekce potřebuje, musí ho odvodit zoccurred_onnebo korelačního ID. - Latence: mezi commitem události a jejím doručením k projektoru vzniká okno odpovídající polling intervalu relay. V praxi 100 ms až 1 s; pro nižší latenci přepněte na výstupní transport, který umí push (např. PostgreSQL
LISTEN/NOTIFYnebo Debezium).
13.08 Praktické problémy projekcí
Předchozí sekce ukázaly, jak projekci vybudovat a jak události spolehlivě doručit. V praxi se ale objevují problémy, které z jednoduchých ukázek nejsou patrné. Tato sekce pokrývá nejčastější z nich – idempotenci, chybové stavy, rebuild a eventual consistency z pohledu uživatelského rozhraní.
Idempotence projektorů
Asynchronní transport (RabbitMQ, Redis Streams, Amazon SQS) garantuje doručení zprávy alespoň jednou (at-least-once delivery). Zpráva se proto může doručit opakovaně – po timeoutu, restartu workeru nebo síťovém výpadku. Pokud projektor není idempotentní, opakované zpracování způsobí poškozená data: duplicitní řádky, zdvojené částky, nekonzistentní počty.
Idempotenci lze zajistit dvěma způsoby: upsert (INSERT … ON DUPLICATE KEY UPDATE) místo prostého INSERT, nebo tracking tabulka již zpracovaných událostí.
Chybové stavy a retry strategie
Projektor může selhat z mnoha důvodů: dočasná nedostupnost databáze, neplatný payload u staré události bez upcasteru, nebo bug v projekční logice. Symfony Messenger nabízí dvě hlavní mechaniky pro řešení:
- Retry transport – zpráva se po selhání automaticky vrátí do fronty s exponenciálním backoffem (výchozí: 3 pokusy s násobičem 2).
- Failed transport (dead letter queue) – po vyčerpání retry pokusů se zpráva přesune do samostatné fronty, kde čeká na manuální zásah. Nedojde ke ztrátě události ani k zablokování zbytku fronty.
Pro diagnostiku a opětovné zpracování selhalých zpráv slouží příkazy Symfony Messenger:
bin/console messenger:failed:show– zobrazí zprávy v dead letter queuebin/console messenger:failed:retry– pokusí se zprávy znovu zpracovatbin/console messenger:failed:remove {id}– odstraní neplatnou zprávu
Rebuild projekcí
Možnost přebudovat projekci od začátku je v Event Sourcingu praktická obrana proti chybám v projekční logice. V provozu jde ale o netriviální operaci. Rebuild musí běžet odděleně od normálního provozu projektoru, stará data se musí korektně odstranit a po dokončení musí projekce odpovídat aktuálnímu stavu Event Store.
Eventual consistency a uživatelské rozhraní
Asynchronní projekce vytváří časové okno (typicky milisekundy až jednotky sekund), kdy uživatel provede akci – například potvrdí objednávku – ale read model ještě nemá aktualizovaná data. Po kliknutí na „Potvrdit“ se na výpisu může objevit stále „Draft“.
Není to bug, ale vlastnost eventual consistency. V UI ji lze adresovat třemi zaběhnutými způsoby:
- Optimistická aktualizace UI – Frontend po úspěšné odpovědi na command okamžitě zobrazí očekávaný stav (např. „Potvrzeno“), aniž čeká na aktualizaci projekce. Nejčastější řešení.
- Potvrzovací stránka – Po provedení akce přesměrovat uživatele na stránku, která nezávisí na projekci (např. „Objednávka č. X byla potvrzena“), místo okamžitého návratu na výpis.
- Polling / SSE – Frontend periodicky dotazuje API nebo naslouchá Server-Sent Events, dokud projekce nedorazí do požadovaného stavu.
13.09 Snapshotting
Se stárnutím systému rostou event streamy agregátů. Agregát s tisíci událostmi vyžaduje při každém command handleru načtení a přehrání tisíce řádků z databáze. Výkonnostní problém se v provozu objeví dřív, než tým čeká.
Vzor snapshotting uchová aktuální stav agregátu v pravidelných intervalech – po každých N událostech nebo časově. Při příštím načtení repozitář vyhledá poslední snapshot a z Event Store dotáhne jen události novější než tento snapshot.
Kdy vytvářet snapshots
- Poté, co replay agregátu začne měřitelně zpomalovat – práh závisí na doméně, typicky se pohybuje od stovek po tisíce událostí.
- Periodicky (např. jednou denně) pro agregáty s vysokou frekvencí událostí.
- Na vyžádání – jako optimalizační krok po migraci nebo importu dat.
Aby byl snapshotting funkční, musí agregát implementovat metody toSnapshot(): array
(serializace aktuálního stavu) a statickou reconstituteFromSnapshot(array $state): static
(deserializace). Na rozdíl od reconstituteFromEvents() tato metoda nevytváří apply*()
volání – přímo nastaví properties z uloženého snímku. Je proto nezbytné zajistit, aby se formát
snapshotu vyvíjel v souladu se změnami doménového modelu.
13.10 Verzování událostí (Event Versioning)
Události v Event Store jsou permanentní – jednou uložené se nemažou ani nepřepisují. Doménový model se přitom v čase vyvíjí: přibývají atributy, mění se struktura dat, původní pole se rozdělují nebo slučují. Otázka tedy zní: jak přečíst starou událost novým kódem?
Odpověď je event versioning – strategie, která zachovává zpětnou čitelnost starých událostí i po změně jejich schématu. Nejrozšířenějším vzorem je upcasting: při deserializaci se starší verze payloadu transformuje na aktuální formát, takže doménový model pracuje pouze s nejnovější verzí.
Proč je verzování nezbytné
- Append-only princip – Události v Event Store nelze měnit. Pokud změníte schéma události, stará data zůstávají v původním formátu navždy.
- Replay a projekce – Při přebudování projekcí nebo replay agregátu se přehrávají všechny historické události, včetně těch z prvních verzí systému.
- Dlouhověkost systému – Event-sourcovaný systém může běžet roky. Za tu dobu se doménové požadavky změní mnohokrát a schémata událostí se musejí vyvíjet spolu s nimi.
Vzor Upcaster
Upcaster je objekt, který transformuje payload události z jedné verze do následující. Upcasters se řetězí: pokud existuje událost ve verzi 1 a aktuální verze je 3, proběhne transformace v1 → v2 → v3. Upcasting se provádí při čtení (deserializaci), nikoli při zápisu – původní data v Event Store zůstávají nedotčena.
Konkrétní příklad: rozdělení pole fullName
Představme si reálnou situaci: při spuštění systému událost UserRegistered obsahovala
pole fullName (celé jméno jako jeden řetězec). Později se objevil požadavek rozlišit
křestní jméno a příjmení – vznikla verze 2 se dvěma poli firstName
a lastName. V Event Store ale stále existují tisíce událostí v1 s polem fullName.
V praxi se UpcasterChain integruje do EventSerializer: při deserializaci
se z uloženého záznamu přečte event_type a schema_version, payload projde
řetězem upcasterů a teprve výsledná transformovaná data se předají konstruktoru aktuální třídy události.
Změny, které upcasting neřeší
Upcasting předpokládá, že stará data lze deterministicky přeložit na nový formát. Některé změny tuto vlastnost nemají:
- Sémantická změna pole.
Order.shippingPricepůvodně zahrnoval DPH, od v3 ho neobsahuje. Stará data nelze správně přeložit – DPH sazba v okamžiku vystavení objednávky není v eventu uložená. Upcaster může jen předpokládat (např. konstantní 21 %), což je nepřesné a generuje reporty s chybnými čísly. - Event splitting. Původní
OrderPlacedobsahovalcustomerDatainline. V nové verzi se rozděluje naOrderPlaced+CustomerSnapshotted(samostatný event). Upcaster by musel vytvořit druhý event z prvního, což porušuje princip „1 fyzický event v Event Store = 1 logický fakt“. - Event merging. Dva eventy
ItemAdded+ItemQuantityChangedse v nové doméně spojí do jednohoItemUpserted. Upcasting jdoucí jednou cestou nestačí – potřebujete agregátní transformaci napříč streamem. - Sémantický bug v doménové logice. Stará data byla validní podle starého modelu, ale ten model byl chybný. Replay přes opravený kód vyhodí výjimky.
Tři možné cesty, podle závažnosti:
Stream archivation a storage tiering
Long-lived agregáty (UserAccount, Subscription, LedgerAccount) generují
po letech provozu desítky až stovky tisíc eventů. Aktivní Event Store tabulka
roste, queries pomalují, snapshots musí být časté.
Standardní řešení: storage tiering podle stáří streamu.
- Hot tier (PostgreSQL master) – události za posledních 90 dní, dotazy < 10 ms.
- Warm tier (Postgres replica nebo Doctrine on slow disk) – události 90 dní – 2 roky. Hydration sahá sem jen pro forensické dotazy nebo plný replay projekce.
- Cold tier (S3, Glacier, on-prem object storage) – události starší než 2 roky. Read-only, accessed jen pro auditní reporty a compliance.
Implementace: každou noc se spustí job, který
přesune event_store řádky starší než N dní do event_store_archive tabulky
(nebo přímo do S3 jako Parquet). Repozitář při hydration ve výchozím nastavení cold tier nečte – pokud agregát potřebuje plný replay, operátor explicitně rehydratuje
ze snapshotu novějšího než cold cutoff. Pro audit dotazy funguje zvlášť query
service, který umí číst všechny tři tiers.
13.11 Kdy použít Event Sourcing
Event Sourcing přidává konkrétní možnosti – auditní log, replay, temporální dotazy – výměnou za vyšší složitost infrastruktury i kódu. Před jeho zavedením zvažte, zda v daném kontextu přínosy převažují nad náklady na implementaci a provoz.
Vhodné případy užití
- Auditní log jako doménový požadavek – Finanční systémy, zdravotnické záznamy nebo jakákoli doména, kde je zákonná povinnost uchovávat kompletní historii změn. Auditní log v ES vychází přímo z formátu úložiště – nepotřebuje samostatnou implementaci.
- Komplexní doménová logika s bohatými stavovými přechody – Agregáty procházejí mnoha stavy, každý přechod má svou sémantiku a musí být rekonstruovatelný. Typicky: objednávkové systémy, workflow enginy, bankovní transakce.
- Temporální dotazy – Potřeba „přehrát“ stav systému k libovolnému bodu v minulosti (debugging, analýza, „what-if“ scénáře). U ES stačí replay eventů do daného timestampu.
- Event-driven integrace – Systém produkuje události, které konzumují jiné bounded contexts nebo externí systémy. ES zajišťuje, že žádná událost nebude ztracena – Event Store je zdrojem pravdy pro integraci.
- CQRS s vysokou čtecí zátěží – ES umožňuje vybudovat libovolný počet optimalizovaných read modelů z jednoho event streamu, aniž by bylo nutné měnit write model.
Nevhodné případy užití
- Jednoduché CRUD aplikace – Pokud doménová logika spočívá v základních operacích Create/Read/Update/Delete bez složitých stavových přechodů, ES přináší jen zbytečnou složitost.
- Systémy orientované převážně na reporting – Pokud je primárním požadavkem rychlé čtení a agregace dat (BI, analytics), jsou vhodnější klasická DW řešení nebo OLAP databáze.
- Prototypy a MVP – Rychlá validace produktového nápadu nepotřebuje složitou infrastrukturu. ES lze přidat do zralého systému inkrementálně, pokud se ukáže potřeba – viz Migrace z CRUD.
- Týmy bez zkušeností s ES – Implementace Event Sourcingu bez předchozí zkušenosti přináší vysoké riziko chyb v kritické infrastruktuře (Event Store, serializace, versioning). Doporučuje se začít s menším bounded contextem jako experimentem.
Časté otázky
Co je Event Sourcing?
Event Sourcing je přístup k persistenci stavu, při kterém se neukládá aktuální snímek dat, ale append-only sekvence neměnných událostí, které k aktuálnímu stavu vedly. Aktuální stav agregátu vzniká přehráním těchto událostí od počátku, což poskytuje úplný audit trail a možnost zpětně rekonstruovat jakýkoli stav v čase. Platí princip „current state is derived from the history of events“: nic se v event logu nikdy nepřepisuje ani nemaže. Viz úvodní sekci.
Jaký je vztah mezi Event Sourcingem a CQRS?
Event Sourcing a CQRS jsou dva nezávislé vzory, které se často kombinují. Každý z nich lze zavést samostatně: CQRS funguje i s klasickou ORM persistencí, ES lze implementovat i bez rozdělení na write a read modely. V praxi se však hodí dohromady, protože ES přirozeně vede k oddělení zápisu (event store) a čtení (projekce do read modelů) – což je přesně myšlenka CQRS. Více v sekci Vztah k CQRS.
Co je Event Store a k čemu slouží?
Event Store je specializované append-only úložiště, které persistuje doménové události jednotlivých agregátů chronologicky seřazené. Typicky poskytuje dotazy na event stream konkrétního agregátu pro jeho rekonstrukci a globální dotaz pro čtení událostí všemi projekcemi. Základní metody jsou append(streamId, events) a readStream(streamId); pokročilejší řešení zahrnují optimistické zamykání verzí a publikování událostí do event busu. Implementačně může jít o specializovaný produkt (EventStoreDB, Marten) nebo nadstavbu nad relační databází. Detailní rozbor v sekci Implementace Event Store.
Co jsou projekce v Event Sourcingu?
Projekce je proces, který naslouchá událostem z event store a buduje z nich read modely – denormalizované datové struktury určené pro rychlé dotazy. Projekce bývá jednoúčelová: každý read model má obvykle vlastní projekci, která ho od začátku nebo od posledního zpracovaného offsetu udržuje aktuální. Projekce lze kdykoli přebudovat (rebuild) přehráním událostí od počátku, čímž se bezpečně opravují chyby v read modelech. Praktický příklad v sekci Projekce.
K čemu slouží snapshotting v Event Sourcingu?
Snapshotting je technika, při které se periodicky ukládá serializovaný stav agregátu, aby se při jeho rekonstrukci nemuselo přehrávat celé event history od začátku. Při načtení se vezme poslední snapshot a aplikují se pouze události, které nastaly po něm. Snapshoty řeší výkonnostní problém dlouhých streamů, typicky u agregátů s řádově tisíci událostí – pro krátké streamy jsou zbytečné a přidávají operační komplexitu. Podrobný rozbor v sekci Snapshotting.
Kdy se vyplatí Event Sourcing nasadit?
Event Sourcing se vyplatí tam, kde je historie změn sama o sobě doménově cenná – finanční systémy, sklady, auditované procesy, regulovaná odvětví – nebo kde je třeba rekonstruovat stav v libovolném bodě minulosti. Nevhodný je pro prototypy, MVP a prosté CRUD aplikace. Nasazuje se zpravidla selektivně na jeden bounded context, nikoli plošně na celou aplikaci. Rozhodovací kritéria v sekci Kdy použít Event Sourcing.