0% прочитано

Aggregate у DDD: не кожен зв’язок між таблицями означає один aggregate

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

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

У попередньому матеріалі про момент, коли CRUD уже недостатньо я писала, що DDD стає корисним тоді, коли система вже не просто зберігає дані, а повинна захищати правила.

У статті про межі домену в системі, що росте наступним питанням було: де закінчується одна частина предметної області і починається інша.

Aggregate для мене став наступним кроком у цій логіці.

Найпростіша помилка тут - подивитися на таблиці, побачити зовнішній ключ і вирішити, що пов’язані записи повинні бути одним aggregate.

На практиці це не так. Зв’язок у базі показує, що дані пов’язані. Aggregate відповідає на інше питання: які зміни повинні залишатися узгодженими разом?

Aggregate у цій статті - це межа узгодженості. Усередині неї правила мають залишатися правильними після кожної зміни.

База даних легко підказує неправильну межу

Три таблиці мають зв’язок через product_id
products  id  name  status registrations  id  product_id  status access_records  id  product_id  subject_id  status

Якщо дивитися тільки на схему, можна легко сказати: "усе прив’язане до product, отже це один aggregate".

Але схема не показує, які правила мають виконуватися в одну мить.

Вона також не показує, чи registration може жити довше за одну зміну product, чи access має власний життєвий цикл, чи ці частини змінюються з різних причин.

Тому для aggregate я починаю не з foreign key, а з інваріантів.

Перше питання: яке правило я не можу дозволити порушити

Інваріант визначає, що потрібно змінювати узгоджено
Приклад правила: Активний продуктповинен завжди матикоректну конфігурацію.  Якщо одна команда змінюєі статус, і конфігурацію,вони належать до одногорішення щодо узгодженості.

Якщо система не повинна ніколи бачити стан "product active, але обов’язкової конфігурації немає", ці дві частини мають бути захищені однією зміною.

Це вже аргумент для одного aggregate.

Але якщо інше правило звучить "користувач може отримати доступ до активного product", саме існування доступу не обов’язково має бути частиною тієї самої зміни.

Product може бути активним незалежно від того, скільки access records існує зараз.

Aggregate root є точкою, через яку проходять зміни

Product захищає правило власного aggregate
final class Product{    public function activate(): void    {        if (! $this->configuration->isValid()) {            throw new DomainException(                'Product is not ready for activation.',            );        }         $this->status = ProductStatus::ACTIVE;    }}

Кореневий об’єкт aggregate не потрібен для красивої назви.

Його сенс у тому, що зовнішній код не повинен довільно змінювати внутрішні частини і обходити правила.

Якщо конфігурація належить до того самого aggregate, команда не повинна окремо оновити її в одному місці, а status в іншому, залишивши проміжний недопустимий стан.

Зв’язок зберігається через ідентифікатор, але правила залишаються окремими
Product aggregate Product  |  +-- configuration  +-- lifecycle rules  Access aggregate Access  |  +-- subject  +-- validity  +-- access rules 

Це для мене один із найважливіших моментів DDD.

Access може знати ProductId, але не повинен автоматично тягнути всередину весь Product aggregate.

Йому потрібен ідентифікатор, щоб знати, до чого він належить. Йому не обов’язково потрібно володіти життєвим циклом продукту.

Так зв’язок залишається, але межі відповідальності не зникають.

Aggregate визначає межу сильної узгодженості

Не все, що пов’язане, повинно змінюватися в одному aggregate
У межах одного агрегату: завантаження  |перевірка правил  |зміна стану  |збереження  |фіксація (commit)  Між агрегатами: зміна A

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

Між aggregate ситуація інша. Якщо одна зміна логічно викликає іншу, це вже питання прикладного сценарію і координації.

Іноді обидва aggregate все одно змінюються в одній database transaction. Але навіть тоді вони не зобов’язані перетворюватися на одну модель.

Транзакція і aggregate відповідають на різні питання.

Надто великий aggregate швидко починає заважати

Один великий aggregate збільшує зв’язаність
Один величезний агрегат Product  +-- configuration  +-- registrations  +-- access records  +-- history  +-- settings  +-- policies  Кожна дрібна зміна:завантажити всезаблокувати всезнати всезберегти все

Якщо aggregate об’єднати за принципом "усе має product_id", він дуже швидко почне знати забагато.

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

Зростає кількість блокувань, конфліктів і причин, через які один об’єкт змінюється.

Це хороший сигнал, що aggregate описує не одну межу узгодженості, а кілька різних.

Але надто дрібний aggregate теж може ламати правила

Якщо правило потребує обох частин, штучне розділення небезпечне
Занадто дрібно: ProductStatusProductConfiguration зберігаються незалежно  Можливий результат: status = activeconfiguration = invalid

Мета не в тому, щоб зробити якомога більше маленьких aggregate.

Якщо дві частини разом формують один інваріант, їхнє незалежне збереження створює реальну можливість неправильного стану.

Тому я шукаю не найменшу можливу модель, а найменшу модель, яка все ще може сама захистити своє правило.

Я питаю: хто володіє рішенням

Це просте питання часто швидко прояснює межу.

Хто вирішує, що product можна активувати? Product.

Хто вирішує, що конкретний access можна надати? Access або відповідна модель доступу.

Якщо для відповіді одна модель постійно повинна залізати у внутрішні правила іншої, межа, ймовірно, проведена невдало.

Repository працює з aggregate як з одиницею збереження

Зовнішній код працює через root, а не змінює внутрішні частини окремо
$product = $products->get(    $productId,); $product->activate(); $products->save(    $product,);

У цьому підході repository для мене не є просто обгорткою над Eloquent-запитами.

Його практичний сенс у тому, що він завантажує достатній стан aggregate для перевірки правил і зберігає результат як одну модель.

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

Між aggregate я частіше передаю ідентифікатор, а не цілий об’єкт

Access знає, до якого Product належить, але не володіє Product
final class Access{    public function __construct(        private ProductId $productId,        private SubjectId $subjectId,        private AccessStatus $status,    ) {    }}

Це зменшує випадкову зв’язаність.

Access не отримує можливість змінювати Product лише тому, що між їхніми таблицями є зв’язок.

Якщо для сценарію потрібно перевірити стан Product, прикладний рівень може отримати потрібну інформацію і скоординувати дію.

У AVN Center я прийшла до aggregate через правила, а не через таблиці

Це добре збіглося з досвідом AVN Center.

Спочатку дуже легко мислити через таблиці та зв’язки між ними. Але зі зростанням правил стає важливіше інше: яка частина системи володіє рішенням і який стан вона повинна захистити.

У практичному прикладі з реєстром продуктів я вже показувала один конкретніший крок цієї роботи.

Тут я навмисно залишаю приклад узагальненим: мені важливо показати сам спосіб мислення, а не внутрішню карту AVN Center.

Aggregate і database transaction пов’язані, але це не одне й те саме

Два поняття можуть збігатися в коді, але мають різний зміст
Агрегатні відповіді: «Що має залишатися узгодженимяк єдина модель?»  Транзакційні відповіді: «Які зміни в базі данихфіксуються або скасовуються разом?»

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

Але я не визначаю aggregate через те, які таблиці зараз випадково оновлюються разом.

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

Як я перевіряю, чи дві сутності справді належать до одного aggregate

Я ставлю кілька простих питань:

  • Яке правило буде порушене, якщо ці частини змінити окремо?
  • Чи повинна система бачити їхній новий стан одночасно?
  • Хто володіє рішенням про зміну?
  • Чи одна частина має власний життєвий цикл?
  • Чи можна посилатися на іншу частину лише через її ідентифікатор?
  • Чи спільна модель змушує завантажувати або блокувати непотрібний стан?
  • Чи aggregate захищає один набір інваріантів, а не просто повторює структуру foreign key?

Найбільша зміна для мене - перестати моделювати від таблиць

Таблиці все ще важливі. Індекси, зовнішні ключі та обмеження бази нікуди не зникають.

Але під час моделювання aggregate я більше не ставлю схему БД на перше місце.

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

Цей порядок дуже допоміг мені не перетворювати кожен SQL-зв’язок на об’єктний граф.

Aggregate визначається правилами, а не стрілками між таблицями

Для мене aggregate став зрозумілим лише тоді, коли я перестала думати про нього як про "групу пов’язаних сутностей".

Точніше визначення для практичної роботи таке: це найменша межа, яка може сама захистити важливий інваріант.

Якщо дві сутності просто мають foreign key, цього недостатньо.

Якщо вони повинні змінюватися разом, щоб правило ніколи не було порушене, тоді між ними вже є сильніший зв’язок.

Я починаю aggregate не з таблиці. Я починаю з питання: що система не має права залишити в неправильному стані?

Foreign key показує зв’язок між даними. Aggregate показує, які правила повинні залишатися узгодженими разом.