Як, для чого і де використовувати CQRS, Event Sourcing та DDD

Підписуйтеся на Telegram-канал «DOU #tech», щоб не пропустити нові технічні статті.

Вітаю! Мене звуть Глазунов Павло. Зараз я працюю в компанії Sitecore Ukraine на посаді Developer, маю загальний досвід в ІТ 7+ років і в основному пишу на C# та трішки фронт на TypeScript і Angular. У статті я хочу поділитися своїм досвідом роботи з CQRS/Event Sourcing/DDD, оскільки доводилось бачити як вдале застосування цих підходів, так і не дуже. Стаття націлена на розробників рівня middle і вище, які або чули щось раніше або взагалі не знайомі з цією темою і хочуть спробувати свої сили в DDD і CQRS, але не знають, з чого почати. Стаття має оглядовий характер і не містить технічних деталей.

Domain Driven Design

Domain Driven Design або проблемно-орієнтоване проектування — це підхід, запропонований Еріком Евансом у своїй книзі, присвяченій DDD, при якому ми тримаємо фокус на вивченні доментої області. Тобто це підхід, орієнтований на потреби клієнта/бізнеса і на ефективне вирішення потреб бізнеса саме через розуміння суті проблеми. Такі речі як архітектура, інфраструктура і будь-які новітні технології важливі, але не першочергові. Код завжди має приносити користь, а не просто бути крутим з точки зору архітектури і т. д. Якщо цей підхід орієнтований на потреби бізнеса, то логічно буде говорити на мові цього бізнесу. Саме для цього всі учасники в процесі роботи використовують єдину мову домену або Ubiquitous language. Це робиться для того, щоб всі розуміли один одного і говорили про одні і ті ж речі, тобто використання єдиної мови виключає хибне трактування термінів і неоднозначность. Іншою важливою фішкою DDD є обмежені контексти або Bounded Context, в межах яких ця єдина мова і застосовується. Контекст — це спосіб розділення сутностей бізнес логіки на повністю незалежні і автономні юніти. Обмежені контексти як слабка зв’язність, але на більш високому рівні. Першим етапом як раз і буде побудова Context Map і формування єдиної мови. Насправді єдина мова існує лише в рамках певного контексту, тому формування єдиної мови тісно пов’язане з побудовою контекстів і їх зв’язків. Після завершення даного етапу можна обирати технологічний стек і на основі виділених контекстів формувати мікросервіси Хоч питання архітектури не є першочерговим, але воно теж має значення. І коли доходить справа до її вибору, то найпростішою і найпоширенішою буде n-layer (насправді, я бачив проекти в яких DDD був представлений лише цією архітектурою. Ніякого обмеженого контексту чи агрегатів). У більш просунутому варіанті ця архітектура переростає у гексагональну. На додачу до інверсії залежностей через розшарування отримуємо Порти і Адаптери для взаємодії компонентів в середині системи і зовні. Спускаємося на рівень нижче і наступною необхідною складовою будуть шаблони (як і скрізь шаблони вирішують все): Aggregate, Entity, Value Object, Service, Repository, Event. Я перерахував найбільш вживані шаблони і зараз їх коротко опишу. Repository — інкапсулює доступ до даних і реалізує пошук, також є обгорткою доступу до БД. Entity — об’єкти, які мають унікальний ідентифікатор, бо їхній життєвий цикл має значення для бізнес логіки. По цьому ідентифікатору можна знайти сутність і порівняти з іншими, якщо ідентифікатори однакові, то вони визначають одну сутність. Інакше кажучи набір атрибутів не визначають сутність. Value Object — прості об’єкти, набір атрибутів навпаки визначає унікальність цього об’єкту в системі. Цей об’єкт є незмінним протягом всього життєвого циклу, простіше кажучи він immutable. Aggregate — це сукупність доменних об’єктів, які є одним цілим. Агрегат має корінь через який здійснюється доступ до об’єктів в середині агрегату. Агрегат інкапсулює певний стан, логіку валідації цього стану і поведінку. Service — реалізує певну бізнес логіку, якій не місце в агрегаті/сутності. Агрегати можуть лише містити ключі на інші агрегати і не можуть мати посилань на об’єкти з іншого агрегату, що є золотим правилом DDD. За допомогою контекстів також відбувається розподіл коду на модулі/компоненти/мікросервіси і т. д. Якщо комусь здається, що DDD це складно, то так і є. Часто про цю складністю забувають і додають ще більше складності в архітектуру свого продукту. Повертаючись до прикладу про реалізації шаруватої архітектури, також хочу додати, що в тому проекті кожен шар був повністю ізольований один від одного настільки, що навіть нумератори дублювались на кожному шарі і мапились один в одного. Тому потрібно тримати баланс у розділенню сервісу на рівні (шари), бо вони є логічним розділенням яке повинно не заплутувати, а полегшувати розуміння бізнес-процесів. Те саме стосується контекстів і розподілу на сервіси, тому що ці два процеси часто суперечать один одному. Однією з перевірок запаху коду (архітектури) може бути перевірка автономності сервісу, якщо сервіс не може самостійно обробити прямий запит, то це означає що він не є самостійним і скоріше за все має спільний контекст з іншим сервісом, що в свою чергу означає його несамостійність. Зазвичай говорять, що підхід DDD доцільно застосовувати до великих мікросервісних систем, а не монолітів. І найчастіше великими називають додатки з кількістю мікросервісів від 20 і більше, але я б додав, що доцільно використовувати цей підхід в ентерпрайзі. Проте, що таке ентерпрайз? Мені подобається таке визначення цього терміну: система, яка працює з даними для вирішення потреб бізнесу і при цьому цінність цих самих даних в рази вища за цінність продукту. Переваги від використання DDD лежать на поверхні:

  • Чиста архітектура
  • Бізнес-орієнтований продукт

Але і недоліки теж досить суттєві:

  • Ускладнюється кодова база
  • Важко працювати з традиційними методами розробки ПО
  • Важко масштабувати без додаткових рішень (як при будь-якому традиційному підході)
  • Як і в будь-якій традиційній моделі присутня втрата даних, а саме відсутність проміжкових станів, що вимагає додаткових рішень, наприклад, для аналітики
  • Транзакції гальмують систему, бо для їх цілісності необхідні блокування, які тимчасово уповільнюють роботу з даними. І чим більше транзакцій в системі, то тим більше буде проблем зі швидкістю

Event Sourcing — історична модель мислення

Багато хто б поставив цей розділ в кінець статті, але я по прикладу Грега Янга вирішив піти в правильному напрямку, бо використовувати CQRS без Event Sourcing однозначно принесе користь, але робити навпаки немає жодного сенсу і це є проблема. Ви можете використовувати CQRS самостійно і бути повністю щасливими, але Event Sourcing без CQRS буде ефективним поки в системі невеликий обсяг даних. Тому про це поговоримо далі. Цей підхід полягає в тому, щоб записувати історію всіх подій, які призвели систему в поточний стан в точній послідовності. Проте ES не просто аудит лог з тимчасовими даними про бізнес транзакції в системі, а послідовна історія зміни агрегатів, бо недарма підхід називаеться Event Sourcing. Ваші івенти (їх також часто називають комітами) — це єдине джерело правди. Ідея підходу чимось нагадує систему контролю версій, де ми в EventStore зберігаємо в точній послідовності всі дельти стану нашого агрегату. На перший погляд звучить дивно, але насправді більшість серйозних систем, які нас оточують і з якими ми взаємодіємо, не використовують концепт current state або фінальний стан, а саме: фінансові, банківські і багато інших. Як казав Грег Янг в одному зі своїх виступів, що ваш рахунок в банку — це не просто колонка в табличці, а сума всіх транзакцій, які були здійснені з вашим рахунком (поповнення, різні списання і відрахування). Наприклад, якщо у вас є розбіжність з вашим банком щодо балансу рахунку і банк стверджує що на рахунку 103 долари, але ваша позиція що має бути 207. Ви ніколи не почуєте у відповідь «колонка стверджує що там 103 долари і це все, що ви маєте». Особливості ES:

  • Усі події в системі immutable. Це обмеження в основному пов’язане з тим що доменні події зберігаються в документо-орієнтованій БД і тому їх треба якось серіалізувати/десеріалізувати, але це залежить від реалізації серіалізатора. Деякі ES рішення підтримують версіонування і тому залишається лише обмеження не видаляти поля івентів.
  • Доменні події можна лише додавати, тобто вони append only. Така особливість більше пов’язана просто зі здоровим глуздом, бо видаляти івенти з івент стора більше схоже на постріли собі в ногу або стромлянням палок собі в колеса.
  • Можна реєструвати стільки, скільки завгодно івентів. Оскільки івенти не бажано видаляти, то для таких випадків можна створювати компенсуючі івенти, які будуть новими версіями вже існуючих івентів, але які мають додаткові поля. Також гарною практикою буде створювати нові івенти для виняткових подій, наприклад, в адресі клієнта необхідно виправити помилку або адреса змінилась в зв’язку з переїздом і це дві різні події, а не просто CustomerAddressChanged.
  • Ім’я кожної події має певну семантику, яка говорить про те, що відбулося в системі. Івенти завжди говорять про минуле і вони реально мають розповідати своїм ім’ям що саме змінилось і як саме (наприклад, OrderCanceled, OrderItemAdded, ProductRemoved, OrderPlaced). Правильні імена об’єктів подій мають допомогти з пошуком проблем і дебагом (поганим прикладом є Event1, Ordered, Created).

Нероздільною частиною ES є поняття snapshot — проміжкові зрізи (знімки) стану агрегату в EventStore. Часом для того, щоб отримати фінальний стан об’єкту, треба скласти дуже велику кількість івентів, починаючи від найпершого. Тому з ціллю оптимізації робиться снепшот, і ми будемо відновлювати фінальний стан не від найпершого івенту в системі, а від останнього снепшоту. Попри очевидну користь від цього патерну його слід застосовувати лише тоді, коли на отримання фінального стану витрачається занадто багато часу. Багатьом може здатися очевидним мінусом те що об’єм даних при такому підході росте експоненціально, але це не так. Як видно на попередньому зображенні, то в івентах зберігаються не повні моделі даних, а лише зміна стану. Насправді, зниження швидкості роботи такої системи пов’язане з побудовою агрегатів з усіх доменних подій, які відбулись до тепер. Саме тому ES без CQRS’а не такий корисний і більше буде нагадувати «костиль». Отже, ES — це зберігання серії івентів і схема даних, яка відображає ці івенти є першою похідною від цих івентів. Схема даних в ES-системах є тимчасовою і може бути в будь-який час перебудована або відновлена з івентів. Чим не машина часу?! User cases не змінюються, а поведінка — постійно.

CQS або розділяй і пануй

Command Query Separation був вперше описаний Бертраном Мейєром. Суть підходу полягає в розділені методів на два різних потоки: команд і запитів. По своїй суті шаблон є антипатерном до стеку і черги, бо в ньому запити ніколи не модифікують стан системи і лише команди модифікують стан, але нічого не повертають (умовно нічого). CQS застосовується на рівні компонентів, на відміну від CQRS, який є еволюцією CQS. Переваги такого підходу, як і недоліки, лежать на поверхні:

  • зростає кодова база від розділення методів
  • entity стають зрозумілими, код отримує достатній рівень поділу відношень (SoC)
  • зменшується кількість побічних ефектів і це мотивує більше дотримуватися принципів SOLID
  • код стає легше тестувати, бо сам підхід стимулює писати більше тестів

Я не дарма згадав про цей підхід, бо він не тільки ліг в основу CQRS і їх часто плутають, але й тому що він чудово комбінується з DDD. Якщо точніше, то можна розділяти методи агрегатів на команди і запити, що неодмінно збільшить користь від використання DDD, бо це вважається гарною практикою коли метод або виконує команду (command/mutation/modifier) або запит в один момент часу, а не одночасно. Щодо того, що команда ніколи не повертає даних, то це не зовсім так, бо допускається повертати результат операції або ідентифікатор об’єкта, наприклад.

CQRS — продовжуємо розділяти

Command Query Responsibility Segregation сформульований Грегом Янгом і Джонатаном Олівером на основі CQS і полягає в розділенні операцій читання і запису в обмежених контекстах. Однією з причин появи CQRS це нерівномірний розподіл навантаження на підсистеми читання і запису. Цей нерівномірний розподіл у свою чергу від того, що бізнес-правила або бізнес-логіка зосереджені на етапі запису інформації і одночасно з цим запити на багато частіше використовуються, ніж команди. Коли я тільки почав знайомитися з цим шаблоном, то мені трапилась стаття про те, що CQRS має певні типи і він не просто чорне і біле. Спочатку ця думка була незрозумілою мені, але з часом, коли я познайомився ще з кількома проектами, в яких використаний CQRS, то я чітко зрозумів правидівість тієї статті. Для першого типу необхідно розділити ієрархію класів, тобто для команд і запитів в нас будуть окремі класи, які будуть повними або частковими копіями. Для команд буде клас в форматі доменного об’єкту, а для запитів DTO. Таке дублювання насправді дасть змогу використовувати лише необхідні поля для команди/запиту, що дасть певну мінімізацію даних і відповідно оптимізацію. Також цей розподіл дасть гнучкість у використанні класів і при подальшій оптимізації запитів. Для другого типу необхідно розділити API (застосувати CQS на всіх шарах). Від контролеру до репозиторію наші класи будуть мати окремі методи для читання даних і їх модифікації, які будуть оперувати окремими моделями даних з першого типу. Як і з попереднім типом отримуємо чергову порцію оверхеда, але більше ніж на першому кроці, та ще більше можливостей для оптимізації запитів (різноманітні кеші, масштабувати АПІ окремо один від одного і тд). Слід зазначити, що CQS розділить обов’язки репозиторію на 2 окремі класи: command handler i query handler. Третій тип дасть ще більше оверхеду і оптимізації операцій читання за рахунок розділення БД і свободи вибору різних постачальників БД. Якщо бази різних типів, то треба різні адаптери, що додає складності в кодову базу. На перевагу цьому можна різні бази маштабувати як завгодно і оптимізувати схеми даних. Проте найбільшою проблемою даного типу CQRS буде синхронізація даних в репліках відома як eventually consistent, бо дані в базах для читання і для запису мають відповідати один одному. Можна використовувати кожен тип по мірі необхідності і потребах проекту. Тим самим можна плавно мігрувати на подібну архітектуру без великих поломок і простоїв системи через оновлення. Зараз хочу підвести невеликий підсумок: кілер-фітчею даного підходу є надзвичайно швидкий і оптимізований read-side, який заточений під UI і не тільки, де не потрібно писати складні запити з join-ами або робити на рівні БД views, бо read база оптимізована під читання (як показує мій досвід не дуже приємно, коли в проекті схеми баз однакові і є потреба робити join-йни в MongoDb). Але треба буде змиритися з великим оверхедом і підтримувати eventually consistent баз запису і читання. Насправді, баз для читання може бути дуже багато (під кожен мікросервіс по кілька), вони можуть бути різні (sql, nosql, full-text search), можуть відображати різні схеми даних і різні потреби: база для звітів, база для графіків, база агрегатів і т. д.

CQRS + ES

Як я писав раніше ES не тільки неефективний без CQRS, але і неможливий, бо виглядає як «костиль», тому хочу представити повну картину DDD з CQRS + ES. Бо як наголошував Грег Янг, єдина причина використовувати Event Sourcing — це використання CQRS, але не навпаки. Як видно зі спрощеної схеми, то EventStore як раз і проводить ту лінію Responsibility Segregation. Зараз я більш детально опишу, як це працює. Усе починається з того, що в систему приходить команда, наприклад, додати товар до замовлення. Відповідний команд хендлер перехоплює цю команду і піднімає з івент стора агрегат замовлення (зі сторони коду це виглядає як і в традиційному підході — метод getById). Потім виконуємо відповідний метод на агрегаті (order.AddProductItem) і виконуємо метод Save, щоб зберегти сам агрегат. Тим часом агрегат робить валідацію вхідних даних і свого стану, оновлює свій стан і стріляє івент (ProductItemAdded), який потім записується в БД для запису (насправді доменні події відправляються в брокер повідомлень і вже потім з брокера потрапляють в проекцію). Далі цей івент перехоплює проекція (пласке відображення стейту), оновлює БД для читання і збільшує свій внутрішній лічильник. Проекції так називаються через аналогію з геометрією, де тінь об’ємної фігури на папері є проекцією. Проекція повністю обробляє модель для читання, тобто оновлює дані і дозволяє читати дані (містить методи для читання і методи обробки подій). Проте це не є суворою умовою і можна проекцію розділити на два окремі класи для по принципу CQRS і отримаємо EventHandler і QueryHandler (це вже питання уподобань і зручності). Іншою особливістю проекцій є те, що їх може бути скільки завгодно і вони можуть обробляти які-завгодно події, таким чином можна створити різні моделі даних під різні потреби. Оскільки наш справжній стейт зберігається в EventStore, то дані в read базах можна в будь-який момент видалити і знову відновити дані по івентах і цей процес називається ребілдом проекцій. З власного досвіду скажу, що подібний функціонал обов’язковий до реалізації з наступних причин:

  • Користувацькі сценарії рідко змінюються на відміну від поведінки системи, тобто агрегати мають бути максимально продуманими і доменні події також, бо вони відображають юзер кейси. На відміду від проекцій які відображають поведінку і можуть часто змінюватися або десь може бути допущена помилка, яка легко виправляється і робиться ребілд проекції.
  • Історичні дані, які є в write базі, як раз і дозволяють змінювати поведінку в проекціях за першою вимогою та додавати нові проекції на основі існуючих історичних даних.

Принцип роботи і схема CQRS з ES не така вже і складна. Особливо в порівнянні з деякими ситуаціями, які потрібно правильно обробляти. Перша найпоширеніша задача це валідація пошти користувача (чи є такий вже в системі) і для вирішення цієї задачі є кілька підходів:

  • Агрегат для валідації користується проекцією (read базою), щоб дізнатися чи є така поштова адреса в системі, якщо є то кидає exception і на цьому все. Це один з правильних варіантів.
  • За допомогою компенсуючого івенту. Але є один нюанс. Багато людей переконані що мішати підсистему для читання і запису змішувати не можна, але все не настільки суворо (про це навіть Грег Янг казав). Проте якщо переконання не дають так робити, то можна скористатися патерном Сага. Цей патерн був вигаданий для вирішення проблем з транзакціями в мікро-сервісній архітектурі і в нашому випадку вона теж буде створювати транзакцію. Саги можуть реагувати на івенти, таймери або певний розклад і можуть виконувати будь-які команди в системі, робити запити в проекції і навіть запити до зовнішніх систем (чого не може робити агрегат, бо він не повинен мати побічних ефектів). Сага може обробити подію NewUserNotActivated і виконати одну з команд: ActivateNewUser або SoftDeleteNewUser. Це теж правильний варіант.
  • Нарешті опишу один не правильний варіант, який мені доводилось зустрічати. В базі на колонку Email навішуємо constraint на унікальність. Тепер можна створювати нового користувача завжди і просто обробляти exception з БД в агрегаті.

Наостанок перерахую недоліки (які вже і так очевидні), переваги і сферу застосування зв’язки CQRS + EventSourcing. Недоліки:

  • складність підходу в порівняти з традиційними моделями
  • величений оверхед по коду
  • вимагає приділяти багато уваги проектуванню агрегатів та доменних подій. Не кожного мідла, а тим більше джуна допустять до такого відповідального завдання
  • всі сукупні мінуси CQRS та ES
  • немає готових рішень/фреймворків або якщо вони є, то не покривають всіх потреб. Тому завжди доводиться писати свої велосипеди

Переваги:

  • надзвичайно швидкі операції читання
  • можливість створювати альтернативні гілки подій, різні маніпуляції зі стейтом по відношенню до послідовності івентів
  • широкі можливості для масштабування окремих частин системи за вимогою

Цей підхід доцільно використовувати в DDD системах (мікро-сервіси у обмежених контекстах) з нерівномірним навантаження на read і write підсистеми і їх треба масштабувати, якщо потрібні історичні дані, якщо потрібна звітність і повнотекстовий пошук. Наостанок хочу додати, що всі вище перераховані підходи не є чимось новим і модним. Вони мають свою сферу застосування. Використовувати певний підхід або технологію тільки заради наявності цього в проекті шкідливо, бо будь-який інструмент ефективний не завжди, а лише за певних обставин. Також будь-які збіги з реальними проектами і рішеннями, які я наводив як приклади, випадкові (:

👍НравитсяПонравилось21
В избранноеВ избранном16
LinkedIn
Допустимые теги: blockquote, a, pre, code, ul, ol, li, b, i, del.
Ctrl + Enter
Допустимые теги: blockquote, a, pre, code, ul, ol, li, b, i, del.
Ctrl + Enter

Event Store живий — це приємно.

Отличная статья! Я могу добавить что ES и CQRS реально помогают когда нужно построить высоконагруженную систему типа их тех что используются в ФААНГАХ.
Теорема CAP и ее расширение PACELC как раз объясняет чем вы жертвуете при построении системы с CQRS.
Также хочу добавить что функциональное программирование очень помогает понять общую картину при работе с Event Sourcing, так как проекции это по сути это map-reduce на ваших ивентах.
DDD может существовать абсолютно независимо от CQRS и ES и в DDD сообществе как раз часто упоминается CQRS и ES как один из инструментов решения проблем

Хорошая статья, мне кажется что в заголовке каждой статьи про DDD CQRS должно быть предупреждение — сто раз все обдумайте прежде чем приниматься за имплементацию этого подхода. Сформулируйте проблему которую вы собираетесь решить и подумайте стоит ли это тех затрат которые вам предстоят. У меня сейчас перед глазами эталонный пример неудачного внедрения и ситуация сейчас прямо мыши и кактус плюс чемодан без ручки.

Плюсую. В нас теж був досить великий проект, що пішов на смітник.

Так собственно у Фаулера сразу в первом же абзаце есть подобный дисклеймер. martinfowler.com/bliki/CQRS.html если кто решил внедрить не почитав то последсвия не заставят себя долго ждать.

Не все Фаулера читают. К сожалению.

Когда не нужно использовать CQRS (помимо указанных накладных расходов на поддержку инфраструктуры) — когда в задаче нет бизнес процессов с ограничениями на стороне wrtite-model (обработка статистики, загрузка и обработка batch data files, ввод данных без необходимости аудита, сложных кросс валидаций или realtime collaboration)

Спасибо за статью, все кратко и хорошо описано!)

Все три шняги отлично подходят для того, что бы раздуть небольшой круд до размеров Амазона и потом годами доить заказчика.

Очевидный пример использования ES — как имплементированы Azure Durable Functions

Привет Паша, после Code WW решил не останавливаться?)
Это все прикольно, но все же хотелось бы видеть более детально по поводу межсервисной коммуникации, саги, проблемы поддержки контрактов когда у тебя 3 сервиса A, B и C, и идет зависимость A->B, B->C, C->A.

Привіт, Влад)
Одразу скажу, що це моя перша стаття і певні моменти могли вислизнути або я погано їх висвітлив.
Також додам, що відповідь на твоє запитання заслуговує окремої статті і тому коротко опишу своє бачення задачі.
Отже, я бачу пару основних проблем, які треба буде вирішити і вони на пряму не залежать від CQRS чи ES:
1. Для міжсервісної комунікації потрібно обрати правильний брокер повідомлень
2. Для контрактів спочатку визначитися з моделю комунікації (Request-Response, Command або Publisher-Subscriber) і тестуванням контрактів (Consumer driven чи Provider driven)
3. Вибір моделі комунікації сервісів з базою/базами даних

В статье показаны классические юз кейсы, о которых давно расписанно в книжках на английском. Как например с валидацией когда в заказ можно вставить товар только если он есть на остатках? Или получить дилерскую цену для клиента, или ещё туча бизнес правил которые есть в реальной жизни

Проте у багатьох людей досі дана тема викликає ряд проблем як у розумінні, так і у реалізації. Дана стаття не претендує бути енциклопедією чи біблією, а лише дає відправну точку для читача.

Ну ок, просто подобных статей и на английском и на русском полно. Я имею ввиду классические примеры с товарами и заказами. А вот статей рассматривающих конкретные проблемы как упоминали выше с сагами, зависимостями, валидацией не так уж много. Это было бы интересно.

Я Вас почув. Пішов писати продовження статті)

Проблеми починаються там, де з’являються крос-агрегатні залежності )

А как без них? Заказ — агрегат, Клиент — агрегат вот и зависимосить

Просто переформулював питання на терміни.

Ради чего это все городить, можно в 2 словах или метриках? Я участвовал неоднократно в проектах, где применялся как бы CQRS, но только без Event Sourcing. Поначалу все было круто, на тупой круд создавались обьекты типа UpdateFooCommand, ReadBarQuery и подобное. Уже на 3-ю неделю начался адок в связи с неидеальностью реального мира. Команды начали вовсю дергать у себя внутри квери и наоборот. И вообще выхлопа плодить объекты на каждый чих вместо создания нового метода у сервиса я не увидел.

Наприкінці статті я описував доцільність використання CQRS+ES. Але ще раз продублюю. Для проектів в яких:
— є DDD на мікросервісах
— нерівномірне навантаження на read і write підсистеми
— є потреба в історичних даних
— потрібні звіти
— потрібен повнотекстовий пошук
Останні 4 пункти можуть бути представлені в різних комбінаціях.
Якщо чисто брати Ваш приклад, то замало інформації, щоб щось конкретне порадити. Але ймовірніше за все CQRS був не потрібен і можна було обійтися ETL або Reporting Database підходами.

Предполагаю, его неправильно приготовили. Обычный CQS очень облегчает жизнь по сравнению со стандартным подходом (проверено лично на двух проектах). По Вашему посту не видно проблемы конкретно CQRS — видимо, она вылезла ещё на уровне CQS.

Уже на 3-ю неделю начался адок в связи с неидеальностью реального мира. Команды начали вовсю дергать у себя внутри квери и наоборот.

Приведите пример, плз — обсудим.

Уже на 3-ю неделю начался адок в связи с неидеальностью реального мира. Команды начали вовсю дергать у себя внутри квери и наоборот.

Это проблема не самого подхода, а того, что конкретно у вас на проекте люди не понимали грань между добром и злом, делали ***к-хуяк, и в результате наговнокодили.

Трошки офтопу про оформлення

Це наче пнг s.dou.ua/...​iles/image001_zlcMGIm.png але виглядає як жпег

І відчуття, що це саме той випадок, коли треба використати оті UML діаграми подій, що вимагали ліпити в дипломні і інші роботи

Агрегат для валідації користується проекцією (read базою), щоб дізнатися чи є така поштова адреса в системі, якщо є то кидає exception і на цьому все. Це один з правильних варіантів.

Эта проверка не вызовет проблем в любом случае, т.к. конкурентность вряд ли будет присутствовать. Гораздо интереснее проверять таким образом быстро меняющиеся данные).

Насправді констрейт в БД чи не найзручніший варіант для данних, які швидко міняються
Більше того, не розкрита тема, чому цей варіант неправильний? У нас буде роллбек транзакції, івенти в стор не попадуть

Мабуть, варто було уточнити, що констрейт накладається на БД для читання. Подібні прийоми не зробиш з івент стором, бо там не може бути колонки Email (а якщо буде для зручності, то це костиль), а лише колонка Payload. Отже, варіант з констрейтом не правильний, бо івенти зайві івенти залишаться в івент сторі, а видаляти івенти навіть зайві, то взагалі — гріх.

Всё же я имел в виду немного другое). Приведённый пример просто далёк от того, где могут быть проблемы с read проекциями для валидаций (а это — конкурентная среда). Представим себе ситуацию, когда нужно отправлять нотификацию, если определённое число будет кратным, допустим, 25, и активно прилетают ивенты с инкрементом или декрементом этого числа, отличным от 1. Варианты сайд-эффектов при такой валидации могут быть очень весёлыми: как ошибочные пропуски нотификаций, так и больше одной нотификации, когда должна была быть одна.

Тоді в мене є два варіанти:
— не використовувати CQRS+ES для подібної задачі
— дана логіка з нотіфікаціями повинна бути в сагі, яка ловить всі івенти даного типу, сторить потрібні дані в оперативній пам’яті і там хендлить конкурентість

У меня тоже).
1. Обновление read модели в момент приёма ивента за одну транзакцию (в зависимости от того, как read модель обновляется в конкретном случае, могут быть варианты — сагой или как-то иначе, но в общем так).
2. Акторы, которые идеально справляются с юз-кейсом высококонкурентного изменения одной сущности, предоставляя distributed lock из коробки (был опыт внедрения MS Orleans под такую задачу — там даже не имеет значения, CQRS+ES или что-то другое).

В данном случае активно используют версионность комманд и евентов и понятие агрегата как хранителя консистентного состояния определенного набора ентитей (то-есть для каждого aggregate_id существует одна машинка состояний в одном экземпляре)

Гораздо интереснее проверять таким образом быстро меняющиеся данные).

В нормально спроектованих ЗУБД — все буде ок
В мускулі — гииии

И что Вам даст «нормально спроектированная СУБД» на read проекции, если она eventually consistent в любом случае? Мы обсуждаем не тот юз-кейс.

СУБД тут взагалі ні до чого. CQRS — це про розподілені системи та те, що в них замість транзакцій БД: unit of work та eventual consistency.

Если рассматривать агрегат как хранитель консистентности ентитей, то он должен обладать состоянием, которое позволяет ему однозначно отреагировать на входящую команду. Read-model для него совершенно чуждая зависимость.
В случае ES у него есть список предыдущих событий и команда.
Для маленького числа изменений при получении конкрентной команды можно перебирать все события определенного типа и принимать решение на их основе.
Для большой длины ленты событий можно создавать и хранить нужные данные в памяти (и кешировать, но лучше не надо!).
Для проверки уникальности значений в списках и других объемных структурах их не всегда возможно рационально поместить в память. Тогда можно: а) шардировать (разбивать агрегаты) по каким либо хешам на основе данных ентиней — email, дата рождения, етс (и больно бъемся о проблему миграции если выбранные значения не постоянны); б) сохранять эвент и используя саги/хореографию событий и комманд реагировать постфактум согласно бизнес требованиям; в) использовать enreach-interceptor на входе, который по данным в комманде может запросить набор значений у read-model репозитория/билдера до текущей версии и добавить их в комманду, расширив её (например признаком дублируемости или списком агегатов с таким email). Получившуюся команду можно запаковать envelop-interceptor в batch комманду и отослать в batch-interceptor для рассылки другим агрегатам на проверку/обработку забаненых пользователей например. В этом случае, комбинируя интерсепроры в пайплан, можно осуществлять более сложные кросс-агрегатные валидации на больших объемах данных.

По задаче выбора объема и ответственности конкрентного агрегата очень реккомендую цикл из трех статей Вернона — dddcommunity.org/library/vernon_2011/

Идеи по поводу интерцепторов и их организации для обработки комманд берем из классики по обработке сообщений www.enterpriseintegrationpatterns.com/patterns/messaging
(При этом агрегаты выступают как приложения, хотя по onion model будут внутри, а инфраструктура с пайплайнами снаружи)

Всё это звучит как адовый rocket science. Основной вопрос, который сразу возникает у меня в голове — для решения какой именно бизнес-задачи может быть оправдано такое существенное усложнение архитектуры? Возможно, есть, хотя бы, какие-то rules of thumb по требованиям к масштабируемости, сложности бизнес-логики, требованиям к audit trail / you name it?

Адовые задачи, некоторые усложнения из complicated области. Выбирайте решения исходя из требований. Реализации могут быть очень простыми и очевидными. Всякие конфигурации делегатами и худами в фреймворках сейчас не кажуться никому сложными.

По требованиям к масштабируемости и сложности могу привести два примера:
— процессинг с 15 млн карточных транзакций в месяц привязанных к акционным, бонусным и плюшечным бизнес программам. Частота смены логики програм по тысячам партнеров — в среднем раз в полгода-квартал. 5 dev, 3 тестировщика, 2 года c 99.95% availability.
— нишевая SAAS ERP с поддержкой таких областей как: управление производством (шедулинг и алокейшен ресурсов, мониторинг линий, отчеты и поддержка основных операций); бухгалтерия; персонал (зарплаты, отпуска, выходные, бонусы, больничные); продажи (расчет предложений, встречи, контакты, букинги, SMS/chat/email коммуникации, инвойсы); сервис (расписания, рабочие ордера); логистика (поставщики, экспедиторы, склад); 300+ скринов, 1200+ read-models, 150+ агрегатов. 5 девов/2 тестировщика, 3 года бесперебойной работы

Спасибо! С первым примером понятно. Во втором случае, я могу понять целесообразность DDD и, наверное, даже CQRS, но не ES.

Динамика — большая часть приложения может работать на неустойчивых каналах связи или параллельно (в зависимости от задачи). Соответственно, там где важен оффлайн команды кидаются в очередь на отправку и сразу применяются на UI, там где важнее взаимодействие и канал стабилен(часто офисные задачи) — команды отстреливают с конфликтами. В обоих вариантах web интерфейс слушает апдейты read-model и обновляет состояние сопоставляя его с текущим. Без ES на порядок сложнее поддерживать оба юзкейса.
Тестирование упрощается — вместо юнит тестов тестируются события, генерируемые на определенный набор команд, при пустой или фиксированной истории событий. Иногда при возникновении пограничных багов делается просто слепок истории событий до момента ошибки и фиксируется результат ошибки или требуемый результат как тест на баг-как-фича. Прогон таких тестов перед релизами помогает понять что исправлено, и что нужно доносить клиентам. (Например, клиент очень хочет менять начисление зарплат после отчетного периода — мы говорим что нет, и отправляем на сайт налоговой. А оставленный тест спасает от настырного клиента который хочет решить туже задачу поменяв логику взаимодействия внутри агрегата под другим соусом).
Ну и работа с данными — все read-model билдятся динамически. Из одного потока событий можно создавать множество проекций, при этом оставляя старые и отдавая новые только ограниченному числу клиентов для канареечного теста новых фич.
Агрегаты меняются постоянно с разрастанием функционала, но это не сложная задача и шариться в команде на примерах «хороших» кейсов. О read-model вообще не парились — если кто-то просадил перфоманс — в мониторинге время отклика покажет и это только один билдер который можно изолировать и написать несколько proof of concept для решения задачи.
А вот сами эвенты — это хранители всех ошибок и неверных решений — на каждый агрегат может появится папка deprecated в которой хранятся все обработчики и контракты неверно спроектированных эвентов, которые создавать больше нельзя, но обработать обязан.

Интересно как построен интерфейс в случае ERP. В классическом CRUD пользователь сразу видит результат команды. Например если создал заказ то видит новый заказ на UI. При CQRS создание, фактически обновление read-model происходит ассинхронно и не факт что произойдет. Можно по подробней рассказать этот момент? И про команда сразу отобразается на UI.

Простейшая конкурентная модель просто следит за порядком версий команд на back-end и отфутболивает неверные. Сложнее — мерджинг стратегии.

На клиенте используем model-view-update или mvvm с полноценной объектной моделью и команды применяются к модели меняя ее состояние.
Часть полей данных в модели имеет two-phase состояние с origin и current значениями и их версиями и обрабатывают two-phase commit protocol для обновления. Могут быть вложенные поля, тогда методы протокола просто делегируются всем детям по цепочке вложенности.

Уведолления о обновлениях приходят по web-socket как список вида readmodel;version;[list of ids] по которому подписавшиеся модели запрашивают обновлённые данные.

Единственное но — нет единого шаблона на все случаи. Каждый тип интерфейсов может использовать или блокировки или мержинг или комбинировать. Много rxjs для апдейтов моделей в определенном порядке, уменьшения числа запросов

Давайте на примере создания заказа. Пользователь создает заказ, на бэкэнд посылается команда и независимо от результата обновляется UI, и пользователь видит, что заказ создан?

И еще несколько вопросов
Какая БД используется в качестве write-store?
Архитектура монолит или микросервисы?

Ок, на примере заказа в офлайне.
Пользователь заполняет поля в интерфейсе. Поскольку скорее всего это будут новые агрегаты, то для них создаются комманды создания агрегата с новым id, комманды создания и обновления вложенных entity (со своими уникальными id). Набор комманд применяется к UI упаковываем в батч и ставим в локальную очередь. Батч на сервере применяем целиком — сохраняем события в ленту в рамках внешнего unit of work. Вложенные и генерируемые интерсепторами комманды могут работать в пределах units of work интерсепторов со своими стратегиями сохранения. Для новых агрегатов до отправки версии не определены. После отправки батча для каждого id агрегата получаем ответ с новыми версиями и ошибками валидации.
Если пользователь на странице — применяем ответ — обновляем клиентские версии, подсвечиваем ошибки.
Если ушел — зависимости от задачи можем просто показать глобальным обработчиком ошибку, либо в коде viewModel блока или страницы в методе onCommit создать подписку на ошибки конкретных агрегатов и обрабатывать их (например, в попапе предлагаем вернуться на страницу и при загрузке view model применяем команды к начальному состоянию, обновляем интерфейс и показываем ошибки).

Редактирование уже отправленных данных — это уже другие задачи :)

Amazon RDS c плоскими таблицами и серелизацией евентов, база слоится по хешам клиентов, областям, агрегатам.
Архитектура повторяет структуру комманды разработки — монолит с хорошими контекстами по предметным областям и feature switch.

По редактированию пока писать статью с примерами не готов, по-этому воспользуюсь подсказкой зала и кину ссылку на статью с одной из возможных реализаций flpvsk.com/...​irst-apps-event-sourcing и на github со второй реализацией github.com/...​vent-sourcing-application

PS. в обоих случаях вместо команд используют эвенты, но это не эвенты доменной модели, скорее это протокол мапинга комманд и представления read-model. Иcпользовать там доменные ивенты врагу не пожелаю.

Audit trail получаете из коробки, все действия фиксированы, но в случае PCI DSS и GDPR вместо пайлоада часто хранятся хеши, id данных на внешних железках или кодированые данные.

Подписаться на комментарии