<? phpukraine СПІВБЕСІДИ
⌕Пошук по платформі
АРХІТЕКТУРА · SENIOR

Що таке агрегат у DDD і як обрати його межі?

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

У нас клас `Order` з колекціями позицій, доставок, платежів і повернень. Зберігається через одну транзакцію на 40 таблиць і раз на день падає з дедлоком. Що тут не так з межами?
Чим агрегат відрізняється від просто Eloquent-моделі з relations?
Чому всередині агрегату не можна тримати посилання на об'єкт іншого агрегату, а тільки його id?
Правило «один агрегат на транзакцію» - догма чи є випадки, коли його свідомо порушують?
DDD агрегат інваріанти транзакції оптимістичне блокування

Агрегат описують через межу узгодженості: група об'єктів, яку зберігають і завантажують як одне ціле, бо інакше бізнес-правило не гарантувати. Корінь агрегату - сутність, через яку з ним розмовляє весь зовнішній код; внутрішні сутності не мають публічних репозиторіїв і напряму не змінюються. Усе це заради того, щоб після кожної успішної транзакції стан агрегату був валідним. Якщо Order каже «сума позицій не перевищує кредитний ліміт», додати позицію можна лише методом addLine(), бо тільки там видно всі позиції одночасно.

Звідси й критерій межі. Випишіть інваріанти і для кожного запитайте, чи він мусить виконуватися в момент коміту, чи може побути порушеним кілька секунд. «Сума рядків дорівнює підсумку документа» порушитися не може взагалі, тому рядки лежать всередині. «На складі є залишок» у реальному житті порушується, у бізнесу є процедура на цей випадок (скасування, довезення), тому резерв виносять в окремий агрегат, а узгодження йде подією. Решта зв'язків, якими розробники зазвичай мотивують великий агрегат («у клієнта ж є замовлення»), це навігація по даних, і її місце в read-моделі, а не в межі транзакції.

Правило «один агрегат на транзакцію» випливає з попереднього. Якщо одна транзакція змінює два агрегати, ви неявно оголосили, що між ними теж є інваріант, і тоді незрозуміло, навіщо вони окремі. На практиці звідси береться ще й свобода розміщення: поки Order і Shipment комітяться окремо, їх можна рознести по модулях або базах без переписування домену. Зв'язок між кроками гарантує збережене повідомлення, а не база: рядок в outbox у тій самій транзакції, воркер, обробник. Якщо друга дія може провалитися бізнесово, потрібна явна компенсація замість rollback.

Посилання на інші агрегати тримають ідентифікатором, і причина тут суто практична. Живий об'єкт у полі змушує репозиторій тягнути чужу таблицю, рано чи пізно його змінюють, і в коміт потрапляє другий агрегат. Типізований CustomerId замість int додає ще й перевірку на етапі статичного аналізу: PHPStan не дасть передати OrderId там, де чекають клієнта. Коли методу агрегату справді потрібні дані з-за межі, їх передають аргументом - $order->applyDiscount(CustomerTier::Gold), рішення ухвалює application-сервіс, який цей рівень прочитав.

Ціна завеликого агрегату видна в продакшені раніше, ніж у дизайні. Усі записи в нього серіалізуються: з версією на корені два одночасні запити дають ConcurrentModification одному з них, навіть якщо вони торкалися зовсім різних полів; з песимістичним SELECT ... FOR UPDATE другий чекає й упирається в lock wait timeout. Колекції всередині теж мають свою ціну: агрегат завантажується цілком, тому hasMany на тисячі рядків означає тисячі рядків у пам'яті на кожну зміну статусу. Класичний приклад з книг - Product з усіма Review: інваріанта між ними немає, рейтинг рахується окремо, а розмір росте нескінченно. Коли інваріант вимагає не самої колекції, а лише суми чи кількості, у корені тримають це похідне значення, а рядки виносять за межу.

Оптимістичне блокування згадують разом з межами, бо без нього опис агрегату неповний. Правило перевіряється в пам'яті на завантаженій копії, тож між SELECT і UPDATE стан у базі міг змінитися: два паралельні addLine() прочитають однакову суму, кожен визнає ліміт дотриманим, і обидва запишуться. Умова WHERE version = ? з перевіркою affected rows робить конфлікт видимим; у Doctrine це #[Version], у Laravel пишеться руками. Невдалі апдейти корисно логувати з розбивкою по методах - саме ці лічильники показують, де межа агрегату проведена надто широко.

// Корінь агрегату: позиції всередині, бо від них залежить інваріант суми
final class Order
{
    /** @var list<OrderLine> */
    private array $lines = [];

    private function __construct(
        private readonly OrderId $id,
        private readonly CustomerId $customerId, // інший агрегат - лише id
        private readonly Money $creditLimit,
        private OrderStatus $status,
        private int $version,                    // для оптимістичного блокування
    ) {}

    /**
     * Єдиний спосіб додати позицію: інваріант перевіряється тут, а не в контролері.
     *
     * @throws OrderAlreadyPaid|CreditLimitExceeded
     */
    public function addLine(Sku $sku, int $quantity, Money $unitPrice): void
    {
        if ($this->status !== OrderStatus::Draft) {
            throw new OrderAlreadyPaid($this->id);
        }

        $line = new OrderLine($sku, $quantity, $unitPrice);
        $total = $this->total()->plus($line->amount());

        if ($total->greaterThan($this->creditLimit)) {
            throw new CreditLimitExceeded($this->id, $total, $this->creditLimit);
        }

        $this->lines[] = $line; // стан змінюється лише після перевірок
    }

    /** Копія назовні: зовнішній код не має доступу до внутрішніх об'єктів */
    public function total(): Money
    {
        return array_reduce(
            $this->lines,
            static fn (Money $sum, OrderLine $line): Money => $sum->plus($line->amount()),
            Money::zero($this->creditLimit->currency()),
        );
    }
}

// Репозиторій: один UPDATE на агрегат, версія - умова, а не просто колонка
public function save(Order $order): void
{
    $affected = $this->db->update(
        'UPDATE orders SET status = ?, total_minor = ?, version = version + 1
         WHERE id = ? AND version = ?',
        [$order->status()->value, $order->total()->minor(), $order->id(), $order->version()],
    );

    if ($affected === 0) {
        throw new ConcurrentModification($order->id());
    }
}
Що критерій межі - інваріант, який має виконуватися транзакційно, а не «сутності, які логічно пов'язані» чи схема таблиць.
Що корінь агрегату - єдина публічна точка входу: зовні не дозволено брати внутрішню сутність і змінювати її напряму, бо тоді правило перевіряти нікому.
Правило «одна транзакція - один агрегат», і що між агрегатами узгодженість стає eventual: доменна подія, черга, outbox.
Що посилання на інший агрегат зберігається як типізований ідентифікатор (`CustomerId`), а не як завантажений об'єкт; інакше межі зливаються і тягнеться половина бази.
Розуміння ціни великого агрегату: довга транзакція, конфлікт на одному рядку версії, зростання колекцій до тисяч елементів і неможливість паралельно обробляти незалежні зміни.
Що для самої перевірки конкурентності потрібен механізм: номер версії на корені (оптимістичне блокування) або `SELECT ... FOR UPDATE`, і що без нього інваріант порушиться на двох одночасних запитах.
Виводити межу з ERD: «у `Customer` є замовлення, у замовлення є позиції, значить клієнт - корінь». Тоді один агрегат покриває пів бази і будь-яка зміна адреси конфліктує з оформленням замовлення.
Називати агрегатом звичайну Eloquent-модель з `hasMany`, у якій можна зробити `$order->items()->create([...])` ззовні: правило «сума позицій не перевищує кредитний ліміт» не виконується ніде.
Повертати з кореня `getItems(): array` з живими об'єктами замість `readonly` копій або DTO, після чого виклик `$order->getItems()[0]->setQuantity(99)` обходить усі перевірки.
Тримати внутрішнє посилання `private Customer $customer` замість `CustomerId`: репозиторій агрегату починає join-ити чужу таблицю, а зміна клієнта потрапляє в ту саму транзакцію.
Змінювати два агрегати в одному `DB::transaction()` і вважати це нормою, бо «так простіше»: далі зростає ймовірність дедлоків і зникає можливість рознести ці сутності по модулях.
Додати версію в таблицю, але не порівнювати її в `UPDATE ... WHERE version = ?`: оптимістичне блокування без перевірки affected rows не блокує нічого.
ПОРАДА

Назвіть інваріант уголос і запитайте, чи він має виконуватися в момент коміту. «Сума позицій не більша за ліміт замовлення» - має, значить позиції всередині `Order`. «Товару на складі достатньо» - не зобов'язаний, бо склад змінюють інші люди й інші процеси, значить резервування живе в окремому агрегаті й узгоджується подією. Додайте, що агрегат ви перевіряєте на двох одночасних запитах: якщо правило ламається без версії на корені, межа описана, а механізм захисту ні.

оновлено 3 жовтня 2026 · ліцензія CC-BY-SA-4.0 Знайшли неточність? Напишіть →
ПЕРЕВІРТЕ СЕБЕ

Агрегат - це транзакційна межа узгодженості. Всередину входить рівно те, без чого не перевірити бізнес-правило в момент збереження; зв'язки з іншим агрегатом тримаються за id і узгоджуються eventually. Схема бази й можливості ORM - наслідок рішення, а не його причина, а жорсткого обмеження на кількість сутностей не існує: є обмеження на кількість конкурентних записів, яку агрегат серіалізує.