0% прочитано

DDD і транзакції: де проходить справжня межа узгодженості

Одна бізнес-операція не завжди означає одну велику транзакцію. На практиці я відділяю те, що повинно змінитися узгоджено в межах одного aggregate, від побічних дій, які можуть виконатися після commit.

14 жовтня 2026 р. 10 хв читанняЛаравель

Після матеріалі про aggregate і межу узгодженості наступне питання для мене було майже неминучим: якщо aggregate задає межу узгодженості, то де саме повинна починатися і закінчуватися транзакція?

Раніше мені було легко мислити так: одна бізнес-операція дорівнює одній транзакції.

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

Тепер я дивлюся не на розмір сценарію, а на те, який стан система справді повинна змінити узгоджено.

Межа узгодженості (consistency boundary) для мене відповідає на питання: який стан повинен бути або повністю змінений, або повністю відкотитися разом?

Одна бізнес-операція не означає одну велику транзакцію

Не всі кроки мають однакові вимоги до узгодженості
Бізнес-операція: Активація продукту Можливі кроки: 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

Event запускається тільки після успішного commit
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

Координація сценарію і межа 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.

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

Одна бізнес-операція може бути великою. Справжня транзакційна межа часто значно менша: лише той стан, який повинен залишитися узгодженим разом.