Після матеріалі про aggregate і межу узгодженості наступне питання для мене було майже неминучим: якщо aggregate задає межу узгодженості, то де саме повинна починатися і закінчуватися транзакція?
Раніше мені було легко мислити так: одна бізнес-операція дорівнює одній транзакції.
Але з ростом сценарію в цю транзакцію починають потрапляти черги, зовнішні API, побудова великих даних, журналювання, повідомлення і речі, які взагалі не повинні тримати блокування бази.
Тепер я дивлюся не на розмір сценарію, а на те, який стан система справді повинна змінити узгоджено.
Одна бізнес-операція не означає одну велику транзакцію
Бізнес-операція: Активація продукту Можливі кроки: 1. завантажити продукт2. перевірити правило домену3. змінити стан продукту4. зберегти продукт5. записати подію в журнал аудиту6. надіслати сповіщення7. викликати зовнішній сервіс8. оновити кеш9. опублікувати подіюЗ погляду користувача це може бути одна дія.
З погляду узгодженості це кілька різних видів роботи.
Зміна стану Product може бути критичною. Повідомлення або оновлення кешу вже не обов’язково повинні бути частиною тієї самої database transaction.
Якщо я тримаю все разом лише тому, що "це один use case", транзакція стає довшою без доменної причини.
Я починаю з інваріанта, а не з use case
Invariant: Активний продуктповинен мати дійсний обов'язковий стан. Це має бути атомарним: validate |change state |save |commitЯкщо правило звучить "активний продукт завжди має бути в допустимому стані", я хочу захистити саме цей перехід.
Перевірка поточного стану, зміна і збереження повинні бути одним узгодженим рішенням.
Але email про успішну активацію не є частиною цього інваріанта.
Якщо email не відправився, Product все одно може залишатися коректно активованим.
Application Service допомагає побачити межу транзакції
public function handle( ProductId $productId,): void { DB::transaction(function () use ( $productId, ) { $product = $this->products ->getForUpdate($productId); $product->activate(); $this->products->save( $product, ); }); У матеріалі про Application Service і Domain Service я писала, що прикладний сервіс координує сценарій.
Саме тут зручно показати: ця частина є транзакційною, а ця виконується після commit.
Такий код відразу краще пояснює вимоги системи, ніж один великий DB::transaction() навколо всього методу.
Чим довше транзакція, тим довше ми тримаємо ресурс
DB::transaction(function () use ($product) { $product->activate(); $this->products->save($product); $this->externalApi->sync($product); $this->mailer->sendActivated($product); $this->cache->rebuild($product);});Я вже окремо писала про це в окремому матеріалі про короткі транзакції і роботу під блокуванням.
Поки транзакція відкрита, ми можемо тримати блокування і збільшувати шанс конфліктів.
Зовнішній API може відповідати секунди. Mailer може зависнути. Cache rebuild може бути важким.
Якщо жодна з цих дій не потрібна для самого інваріанта, я не хочу тримати їх усередині критичної секції.
Коли одна транзакція все ж краща за event
Це не означає, що я намагаюся винести з транзакції все.
Якщо дві зміни разом формують один інваріант, їх треба зберегти разом.
Наприклад, якщо система не має права побачити новий status без відповідного обов’язкового стану, розривати це через event буде гірше.
Про таку ситуацію я вже писала в статті про випадки, коли одна транзакція краща за event.
Event доречний після факту, а не замість інваріанта
Транзакція: Продукт активовано | vфіксація (commit) Після фіксації: Подія ProductActivated | +-- сповіщення +-- аналітика +-- оновлення пошукуЯкщо друга дія є реакцією на вже успішну зміну, event часто підходить краще.
Наприклад, Product уже активований. Після цього notification, analytics або search можуть відреагувати.
Це добре відділяє доменний факт від побічних процесів. Детальніше я розглядала це в матеріалі про випадки, коли event є правильним вибором.
Побічна дія не повинна бачити стан, який ще може rollback
DB::transaction(function () use ($product) { $product->activate(); $this->products->save( $product, ); DB::afterCommit(function () use ( $product, ) { ProductActivated::dispatch( $product->id, ); });});Це ще одна практична межа, яку я зараз перевіряю.
Якщо listener або job почне працювати до commit, він може побачити старий стан або відреагувати на зміну, яка потім відкотиться.
Тому реакції на вже збережений факт повинні стартувати після успішного commit.
Два aggregate в одному use case не автоматично означають один aggregate
Варіант використання Product aggregate | +-- зміна стану Access aggregate | +-- можливо, створити/оновити Той самий сценарій використання:так Той самий агрегат:не обов’язковоЦе місце особливо легко переплутати.
Один сценарій може змінювати два aggregate. Іноді технічно вони навіть оновляться в одній транзакції.
Але це не означає, що їх треба об’єднати в одну модель.
Я дивлюся на те, чи вони захищають один інваріант або два різні інваріанти, які просто координуються одним use case.
Я шукаю межу через питання про помилку
Запитайте: Якщо надсилання сповіщення не вдається -чи має продукт виконати відкат? Якщо збій відбувається в системі аналітики -чи має продукт виконати відкат? Якщо не вдається виконати зовнішню синхронізацію -чи має продукт виконати відкат? Якщо не вдається досягти необхідного стану продукту -чи має активація завершитися відкатом? Відповіді на ці запитаннявизначають реальну межу узгодженості.Це, мабуть, найкорисніший практичний тест.
Я питаю: якщо цей крок впаде, чи повинна основна зміна rollback?
Якщо відповідь "так, інакше система буде в недопустимому стані", крок, імовірно, належить до критичної межі.
Якщо відповідь "ні, ми можемо повторити його пізніше", це сильний кандидат на роботу після commit.
Мережевий виклик всередині транзакції для мене тепер червоний прапорець
Не кожен network call усередині транзакції автоматично неправильний, але я завжди хочу побачити дуже сильну причину.
Мережа повільна і ненадійна порівняно з локальною зміною в базі.
Якщо зовнішня система недоступна, я не хочу тримати database lock довше лише тому, що весь сценарій випадково загорнутий в одну транзакцію.
Часто кращий дизайн - commit локальний факт, а зовнішню синхронізацію зробити повторюваною і спостережуваною окремо.
У AVN Center межа ставала зрозумілішою разом із правилами
У роботі над AVN Center ця різниця для мене теж стала практичною, а не теоретичною.
Коли сценарій ще маленький, легко обгорнути все в одну транзакцію і не бачити проблеми.
Коли додаються нові правила, реакції та інтеграції, стає важливо відділити те, що утворює допустимий стан предметної області, від того, що лише реагує на вже успішну зміну.
Я навмисно не показую тут внутрішні сценарії AVN Center. Корисний урок загальний: межа транзакції повинна виростати з інваріанта, а не з довжини use case.
Питання, які я ставлю перед тим, як розширити транзакцію
Я перевіряю:
- Який інваріант ця транзакція реально захищає?
- Які записи повинні commit або rollback разом?
- Чи ця дія змінює допустимий стан aggregate, чи лише реагує на нього?
- Якщо цей крок впаде, чи основна зміна теж повинна відкотитися?
- Чи виконується всередині транзакції мережевий або важкий виклик?
- Чи можна цю побічну роботу надійно повторити після commit?
- Чи не тримаємо ми lock лише тому, що весь use case загорнутий одним блоком?
Справжня межа транзакції проходить навколо правила
Для мене DDD і транзакції стали значно простішими, коли я перестала прирівнювати business operation до одного великого DB::transaction().
Спочатку я визначаю інваріант. Потім дивлюся, який стан потрібен для його захисту. Саме ця частина повинна бути сильно узгодженою.
Все інше я перевіряю окремо: чи справді воно повинно rollback разом із головною зміною, чи може відбутися після commit.
Транзакція для мене більше не обгортає весь сценарій. Вона обгортає ту частину сценарію, без якої правило може стати неправдивим.
Одна бізнес-операція може бути великою. Справжня транзакційна межа часто значно менша: лише той стан, який повинен залишитися узгодженим разом.



