Схема агрегата Order в DDD с границами консистентности, Aggregate Root, объектами OrderItem, OrderStatus и Money, связанными с Customer и Product по ID.

Агрегаты в Domain-Driven Design: что это и как проектировать без ошибок

Агрегаты в Domain-Driven Design часто понимают неправильно. Разработчик видит таблицы orders, order_items, products, users, переносит эти связи в объектную модель один к одному и считает, что теперь у него DDD. На деле получается обычная ORM-модель с красивыми названиями. Иногда даже хуже: код становится сложнее, а бизнес-правила всё равно размазаны по сервисам, контроллерам и обработчикам.

Агрегат в DDD нужен не для красоты. Его задача — защищать бизнес-инварианты. То есть правила, которые должны оставаться истинными после каждой операции в системе.

Если упростить до одной фразы:

Агрегат — это граница консистентности, внутри которой бизнес-правила должны соблюдаться всегда.

Именно поэтому агрегаты важны в сложных backend-системах, Laravel-приложениях, корпоративных продуктах, платёжных системах, CRM, ERP и любых проектах, где данные нельзя менять как попало.

Содержание
  1. Коротко
  2. Что такое агрегат в DDD
  3. Что такое Aggregate Root
  4. Зачем нужны агрегаты
  5. Инварианты: главная причина существования агрегатов
  6. Пример агрегата: Order и OrderItem
  7. Как определить границу агрегата
  8. 1. Что должно изменяться атомарно?
  9. 2. Какие правила нужно защитить?
  10. 3. Что можно хранить по ID?
  11. 4. Насколько большим становится агрегат?
  12. Агрегаты и транзакции
  13. Агрегаты в Laravel: где начинается путаница
  14. Eloquent Model не равен Domain Entity
  15. Relationship не равен граница агрегата
  16. Repository агрегата
  17. Частые ошибки при проектировании агрегатов
  18. 1. Делать агрегат по таблицам базы данных
  19. 2. Делать слишком большой агрегат
  20. 3. Разрешать внешнему коду менять внутренности
  21. 4. Делать анемичную модель
  22. 5. Пытаться использовать DDD везде
  23. Практические правила
  24. Где агрегаты живут в проекте
  25. Пример: что должно быть внутри Order Aggregate
  26. Короткий чек-лист проектирования агрегата
  27. Вывод
  28. Читайте дальше

Коротко

Если нет времени читать всю статью, вот суть:

  • Агрегат — это группа связанных объектов, которые изменяются как единое целое.
  • У агрегата всегда есть главный объект — Aggregate Root.
  • Внешний код должен работать только через Aggregate Root.
  • Один агрегат обычно сохраняется в одной транзакции.
  • Границы агрегата определяются бизнес-правилами, а не таблицами базы данных.
  • Между агрегатами лучше хранить идентификаторы, а не прямые объектные ссылки.
  • Большой агрегат почти всегда становится проблемой производительности и поддержки.

Что такое агрегат в DDD

Агрегат — это кластер связанных сущностей и объектов-значений, который рассматривается как один консистентный блок.

Классический пример — заказ.

В заказе могут быть:

Order
├── OrderItem
├── OrderStatus
├── Money Total
└── DeliveryAddress

Снаружи система не должна напрямую менять OrderItem, пересчитывать сумму заказа или вручную переключать статус. Все важные изменения должны проходить через Order.

Почему? Потому что именно Order знает бизнес-правила заказа.

Например:

  • нельзя добавить товар с количеством меньше или равным нулю;
  • нельзя изменить оплаченный заказ;
  • нельзя удалить позицию из уже отправленного заказа;
  • нельзя пересчитать итоговую сумму снаружи;
  • нельзя создать позицию заказа без самого заказа.

Вот это и есть смысл агрегата: не просто хранить данные, а защищать правила.

Что такое Aggregate Root

Aggregate Root — это главный объект агрегата. Через него внешний код взаимодействует со всем агрегатом.

Если агрегат — это Order, то Order является Aggregate Root.
OrderItem внутри него — часть агрегата, но внешний код не должен менять его напрямую.

Плохо:

$orderItem->changeQuantity(5);
$order->recalculateTotal();

Проблема в том, что внешний код знает слишком много. Он сам меняет позицию заказа, сам не забывает пересчитать сумму, сам должен помнить, в каком статусе заказ можно менять. Сегодня не забыл. Завтра другой разработчик забудет. Потом баг, деньги, продакшен, и все внезапно становятся экспертами по “как надо было”.

Лучше:

$order->changeItemQuantity(
    orderItemId: $orderItemId,
    quantity: 5,
);

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

Зачем нужны агрегаты

Агрегаты нужны не для того, чтобы усложнить код. Хотя иногда DDD именно так и используют — как элегантный способ страдать дороже.

Нормальная польза агрегатов:

  1. Защита бизнес-правил
    Правила находятся внутри модели, а не размазаны по контроллерам и сервисам.
  2. Контроль изменений
    Объекты внутри агрегата нельзя менять произвольно.
  3. Понятная транзакционная граница
    Один агрегат обычно изменяется и сохраняется в рамках одной транзакции.
  4. Снижение связности
    Внешний код меньше знает о внутреннем устройстве модели.
  5. Устойчивость к росту проекта
    Когда бизнес-логика усложняется, модель не разваливается так быстро.

Инварианты: главная причина существования агрегатов

Ключевое слово в теме агрегатов — инвариант.

Инвариант — это правило, которое должно быть истинным всегда.

Примеры:

Сумма заказа всегда равна сумме его позиций.
Оплаченный заказ нельзя изменить.
Нельзя добавить товар с отрицательным количеством.
Баланс счёта не может стать отрицательным.
Пользователь не может иметь два активных основных email.

Если правило должно соблюдаться всегда и проверяется при изменении нескольких объектов, эти объекты могут быть частью одного агрегата.

Но важная деталь: не все связанные данные должны быть внутри одного агрегата.

Например, у пользователя могут быть:

User
Settings
Avatar
Activity
Notifications
Sessions

Это не значит, что всё надо запихнуть в один огромный агрегат User. Если Activity просто хранит историю действий, она не обязана быть частью агрегата пользователя. Если Avatar меняется отдельно и не участвует в критичном бизнес-правиле, его тоже не обязательно держать внутри.

Правильный вопрос звучит так:

Какие данные нужны вместе, чтобы защитить одно бизнес-правило?

А не так:

Какие таблицы связаны foreign key?

База данных и доменная модель — не одно и то же.

Пример агрегата: Order и OrderItem

Рассмотрим простой пример заказа.

Плохой вариант:

$order = Order::find($orderId);

$orderItem = $order->items()->find($orderItemId);
$orderItem->quantity = 5;
$orderItem->save();

$order->total = $order->items()->sum('price');
$order->save();

На первый взгляд всё работает. Но бизнес-логика находится снаружи:

  • кто проверяет статус заказа?
  • кто запрещает количество 0?
  • кто гарантирует пересчёт суммы?
  • кто запрещает менять отправленный заказ?
  • кто следит за валютой и округлением?

Теперь нормальный доменный вариант:

class Order
{
    public function __construct(
        public readonly OrderId $id,
        private OrderStatus $status,
        private Money $total,
        private OrderItems $items,
    ) {
    }

    public function changeItemQuantity(
        OrderItemId $orderItemId,
        int $quantity,
    ): void {
        if ($this->status->isPaid()) {
            throw new DomainException('Paid order cannot be changed.');
        }

        if ($quantity <= 0) {
            throw new DomainException('Quantity must be positive.');
        }

        $this->items->changeQuantity(
            orderItemId: $orderItemId,
            quantity: $quantity,
        );

        $this->recalculateTotal();
    }

    private function recalculateTotal(): void
    {
        $this->total = $this->items->calculateTotal();
    }
}

Теперь внешний код не решает, как менять заказ. Он только вызывает метод:

$order->changeItemQuantity(
    orderItemId: $orderItemId,
    quantity: 5,
);

Агрегат сам защищает свои правила.

Как определить границу агрегата

Граница агрегата — это один из самых сложных вопросов в DDD. Универсальной формулы нет, но есть рабочие вопросы.

1. Что должно изменяться атомарно?

Если несколько объектов должны измениться вместе и в одной транзакции, возможно, они принадлежат одному агрегату.

Например:

Order и OrderItem
Invoice и InvoiceLine
Cart и CartItem

Но если объекты могут жить и изменяться независимо, лучше не объединять их насильно.

2. Какие правила нужно защитить?

Если бизнес-правило невозможно проверить без доступа к нескольким объектам, они могут быть внутри одного агрегата.

Например:

Заказ не может быть подтверждён без хотя бы одной позиции.
Сумма заказа должна совпадать с суммой позиций.
Нельзя добавить в корзину больше товара, чем доступно по лимиту.

3. Что можно хранить по ID?

Между агрегатами часто лучше хранить идентификаторы, а не объектные ссылки.

Например:

class Order
{
    public function __construct(
        public readonly OrderId $id,
        public readonly CustomerId $customerId,
        private OrderItems $items,
    ) {
    }
}

Order не обязан содержать весь объект Customer. Для многих операций достаточно CustomerId.

Это снижает связность и не заставляет грузить половину базы ради изменения одной позиции заказа.

4. Насколько большим становится агрегат?

Большой агрегат — почти всегда тревожный сигнал.

Если для изменения одного поля нужно загрузить:

User
Settings
Avatar
Activity
Notifications
Sessions
Orders
Payments

значит, скорее всего, границы выбраны плохо.

Агрегат должен быть достаточно большим, чтобы защищать инварианты, но достаточно маленьким, чтобы не стать монстром.

Агрегаты и транзакции

Агрегат часто рассматривают как транзакционную границу.

Это означает:

одна команда → один агрегат → одна транзакция

Например:

$order = $orderRepository->get($orderId);

$order->changeItemQuantity(
    orderItemId: $orderItemId,
    quantity: 5,
);

$orderRepository->save($order);

Внутри save() может быть транзакция:

DB::transaction(function () use ($order): void {
    $this->orderMapper->save($order);
});

Но важно не превращать это правило в религию. Иногда операция затрагивает несколько агрегатов. Тогда лучше использовать доменные события, eventual consistency, outbox pattern или отдельный application service, который координирует процесс.

Плохой вариант — делать один огромный агрегат только потому, что “так проще сохранить всё за раз”.

Это не DDD. Это страх перед распределённой ответственностью.

Агрегаты в Laravel: где начинается путаница

В Laravel особенно легко перепутать агрегат с Eloquent-моделью.

Например, есть модели:

Order
OrderItem
Product
Customer
Payment

И разработчик автоматически считает: если есть связи Eloquent, значит это один агрегат.

Нет.

Eloquent описывает хранение данных и связи в базе.
Агрегат описывает границу бизнес-консистентности.

Это разные уровни.

Eloquent Model не равен Domain Entity

Eloquent-модель удобна для работы с базой:

$order = Order::with('items')->find($id);

Но доменная сущность должна отвечать за бизнес-поведение:

$order->confirm();
$order->cancel();
$order->changeItemQuantity(
    orderItemId: $orderItemId,
    quantity: 3,
);

Если в модели только fillable, casts, relations и пара scopes — это не доменная модель. Это Active Record.

И это нормально для простых CRUD-задач. Не надо тащить DDD туда, где достаточно формы, таблицы и контроллера.

Но если проект растёт, появляются сложные правила, статусы, деньги, роли, лимиты, проверки, интеграции — тогда агрегаты начинают иметь смысл.

Relationship не равен граница агрегата

Наличие связи:

public function items(): HasMany
{
    return $this->hasMany(OrderItem::class);
}

не означает, что OrderItem автоматически часть агрегата. Это только связь хранения.

Граница агрегата определяется не ORM, а бизнес-правилом.

Repository агрегата

В DDD репозиторий обычно работает с агрегатом целиком:

interface OrderRepository
{
    public function get(OrderId $orderId): Order;

    public function save(Order $order): void;
}

Снаружи application layer получает агрегат, вызывает бизнес-метод и сохраняет результат.

class ChangeOrderItemQuantityHandler
{
    public function __construct(
        private OrderRepository $orderRepository,
    ) {
    }

    public function handle(ChangeOrderItemQuantityCommand $command): void
    {
        $order = $this->orderRepository->get(
            orderId: $command->orderId,
        );

        $order->changeItemQuantity(
            orderItemId: $command->orderItemId,
            quantity: $command->quantity,
        );

        $this->orderRepository->save(
            order: $order,
        );
    }
}

Контроллер при этом не знает, как устроен заказ. Он не пересчитывает суммы, не проверяет статус, не ковыряет внутренние коллекции.

Так и должно быть.

Частые ошибки при проектировании агрегатов

1. Делать агрегат по таблицам базы данных

Если в базе есть foreign key, это ещё не значит, что объекты должны быть внутри одного агрегата.

База отвечает за хранение.
Домен отвечает за правила.

2. Делать слишком большой агрегат

Большой агрегат сложно загружать, сложно сохранять, сложно тестировать и сложно понимать.

Проблема обычно начинается с фразы:

Ну это же всё относится к пользователю.

Да, относится. Но это не значит, что всё должно быть внутри User.

3. Разрешать внешнему коду менять внутренности

Если внешний код делает так:

$order->items[0]->quantity = 10;

агрегат не защищает ничего.

Внутреннее состояние должно меняться через методы Aggregate Root.

4. Делать анемичную модель

Если агрегат содержит только getters/setters, а вся логика живёт в сервисах, то это не доменная модель.

Плохо:

$order->setStatus(OrderStatus::paid());
$order->setPaidAt($date);
$orderService->recalculateSomething($order);

Лучше:

$order->markAsPaid(
    paidAt: $date,
);

5. Пытаться использовать DDD везде

DDD не нужен для каждой таблицы.

Если у тебя простая админская настройка из двух полей — не надо делать:

SettingAggregateRoot
SettingDomainService
SettingRepositoryInterface
SettingChangedDomainEvent

Это не архитектура. Это бюрократия с namespace.

DDD полезен там, где есть сложность бизнес-правил.

Практические правила

Рабочий чек-лист:

1. Найди бизнес-операцию.
2. Определи правила, которые должны быть соблюдены.
3. Пойми, какие данные нужны для проверки этих правил.
4. Сделай один объект точкой входа.
5. Не отдавай наружу изменяемые внутренние коллекции.
6. Между агрегатами используй идентификаторы.
7. Не делай агрегат больше, чем требует бизнес.
8. Сохраняй агрегат как единое целое.
9. Проверяй инварианты внутри доменной модели.
10. Не путай Eloquent-связи с границами агрегата.

Главное правило:

Агрегат проектируется вокруг инвариантов, а не вокруг таблиц.

Где агрегаты живут в проекте

В Laravel-проекте с DDD агрегат может жить в доменном слое:

app/
└── Modules/
    └── Orders/
        └── Domain/
            ├── Entities/
            │   ├── Order.php
            │   └── OrderItem.php
            ├── ValueObjects/
            │   ├── OrderId.php
            │   ├── OrderItemId.php
            │   └── Money.php
            ├── Repositories/
            │   └── OrderRepository.php
            └── Exceptions/
                └── OrderCannotBeChanged.php

Eloquent-модели при этом могут жить отдельно в инфраструктурном слое:

Infrastructure/
└── Database/
    └── Models/
        ├── OrderModel.php
        └── OrderItemModel.php

Так доменная модель не зависит напрямую от базы, ORM и фреймворка.

Это не всегда нужно. Для маленького проекта такой подход может быть избыточным. Но для сложной системы он даёт важное преимущество: бизнес-логика перестаёт быть заложником Laravel-моделей.

Пример: что должно быть внутри Order Aggregate

Хороший кандидат внутрь агрегата:

Order
OrderItem
OrderStatus
Money
DeliveryAddress

Плохие кандидаты внутрь агрегата:

Customer
Product
Payment history
User activity
Notification logs

Почему?

Потому что Customer, Product, Payment могут быть отдельными агрегатами. Заказу часто достаточно знать:

CustomerId
ProductId
PaymentId

Если заказу нужно проверить цену товара, наличие на складе или лимит покупателя, это не всегда означает, что весь Product или Customer должен оказаться внутри Order.

Иногда это решается через domain service.
Иногда через application service.
Иногда через предварительно подготовленные данные.
Иногда через eventual consistency.

Архитектура не сводится к правилу “засунь всё в один объект”.

Короткий чек-лист проектирования агрегата

Перед тем как создать агрегат, ответь на вопросы:

Какая бизнес-операция выполняется?
Какие правила должны остаться истинными?
Какие объекты нужны для проверки этих правил?
Можно ли изменить эти объекты независимо?
Что должно быть сохранено в одной транзакции?
Кто является естественной точкой входа?
Не становится ли агрегат слишком большим?
Можно ли заменить объектную ссылку идентификатором?

Если на эти вопросы нет ясных ответов, лучше не спешить с DDD-терминами. Сначала нужно понять бизнес-процесс.

Вывод

Агрегаты в Domain-Driven Design — это не просто способ сгруппировать сущности. Это способ защитить бизнес-правила и задать понятную границу изменений.

Хороший агрегат:

  • имеет Aggregate Root;
  • скрывает внутренние объекты;
  • защищает инварианты;
  • изменяется через явные бизнес-методы;
  • сохраняется как единое целое;
  • не разрастается без необходимости;
  • не повторяет слепо структуру базы данных.

Для Laravel-разработчика главный риск — перепутать агрегат с Eloquent-моделью. Eloquent удобен для хранения, но границы агрегата определяются не связями hasMany и belongsTo, а бизнес-правилами.

Если помнить только одну мысль, пусть будет эта:

Агрегат — это не структура таблиц. Это граница, внутри которой система обязана оставаться консистентной.

Читайте дальше

Рейтинг
( 1 оценка, среднее 5 из 5 )
Максим Гречушников/ автор статьи
Мне 40 лет, и больше половины жизни я занимаюсь разработкой. За это время я работал с корпоративными системами, highload-проектами, интеграциями, платежными решениями, внутренними платформами и legacy-кодом, который пережил не одного разработчика. Некоторые системы, кажется, переживут и нас. Моя специализация — PHP, Laravel, backend-архитектура, DDD, тестирование, рефакторинг и техническое лидерство. Мне интересны задачи, где нужно соединить код, бизнес, данные, процессы и здравый смысл. Последние годы я много работаю с LLM и AI-инструментами: использую их для проектирования, анализа кода, документации, тестов, ревью, работы с legacy и построения инженерных процессов. Для меня LLM — это не замена разработчику, а усилитель для тех, кто умеет думать системно. В блоге я пишу о Laravel, PHP, архитектуре, тестировании, инженерной практике, управлении разработкой и реальном применении AI в работе программиста.
Понравилась статья? Поделиться с друзьями: