Термины DDD: сущности, значения объектов, агрегаты и доменные сервисы
В рамках курса по Domain-Driven Design цель данной главы - рассмотреть самые фундаментальные строительные блоки предметной области: сущности, значения объектов, агрегаты и доменные сервисы. Понимание их различий и взаимосвязей является базой для устойчивого моделирования, управления изменениями и эффективной интеграции между границами контекстов.
DDD предлагает не только набор концепций, но и методы организации работы над моделью: единый язык разработки, четкие границы транзакций и контрактов между частями системы. В этой главе мы переход от определения понятий к практическим правилам их применения в архитектуре, коде и интеграциях.
- Определение сущности и значения объекта, их различия и случаи применения.
- Как формируются и управляются границы агрегатов и invariants внутри них.
- Роль доменного сервиса: когда следует вынести поведение за пределы сущностей и значений объектов.
-
Практики моделирования и примеры из реальных систем, включая подходы к интеграции и изменениям в доменной модели.
Краткое содержание главы
- Понимание различий между сущностями, значениями объектов и агрегатами, а также ролью доменного сервиса.
- Принципы проектирования границ агрегатов, инвариантов и управления изменениями.
- Применение концепций на примерах моделей доменной области и маршрутов их реализации в архитектуре и коде.
-
Вопросы интеграции между контекстами, контрактные соглашения и эволюция модели.
Основные концепции
Сущности в DDD определяются идентичностью, которая сохраняется независимо от изменений их состояний. Идентичность позволяет системе распознавать «кого» мы имеем в виду во времени, даже если атрибуты изменяются. Важно различать идентичность сущности и её текущие характеристики: две сущности с одинаковыми свойствами, но разной идентичностью, считаются разными объектами.
Значения объектов - это объекты без идентичности, ориентированные на акумуляцию атрибутов, где равенство определяется по значениям полей. Значения объектов обычно неизменяемы после создания: любые изменения приводят к созданию нового экземпляра. Это упрощает сравнение, кэширование и совместную работу в рамках границ контекста, особенно когда требуется предсказуемость и атомарность операций.
Важная задача проектирования - определить, какие детали относятся к значению объекта, а какие к сущности. Например, адрес или деньги часто реализуются как значения объектов: они передаются как целостные, неизменяемые наборы данных. С другой стороны, пользователь или заказ рассматриваются как сущности: их идентичности нельзя потерять даже при смене имени или адреса.
public class Money
{
public decimal Amount { get; }
public string Currency { get; }
public Money(decimal amount, string currency)
{
Amount = amount;
Currency = currency;
}
public override bool Equals(object obj)
{
if (obj is Money other)
return Amount == other.Amount && Currency == other.Currency;
return false;
}
public override int GetHashCode() => (Amount, Currency).GetHashCode();
}
Существуют разные подходы к моделированию: значение объекта может быть в одном классе с поведением (например, Money) или как составная часть сущности. В любом случае неизменяемость и корректное определение равенств - ключевые правила.
Агрегаты - это композиции сущностей и значений внутри границы, управляемой корнем агрегата. Корень агрегата - единственная точка входа для внешних изменений. Все обращения к состоянию внутри агрегата должны проходить через корень, чтобы гарантировать консистентность и инварианты. Внутри агрегата могут существовать как значения объектов, так и дочерние сущности, но внешний мир не должен напрямую ссылаться на внутренние объекты агрегата.
Управление инвариантами внутри агрегата критично: изменения должны сохранять бизнес-правильность и соответствовать правилам доменной модели. Например, в заказе инвариант может быть таким: сумма всех позиций должна равняться общей стоимости заказа плюс налоги, и скидки не могут применяться к позициям сверх их цены.
public class Order
{
public Guid Id { get; private set; }
public IList Lines { get; private set; } = new List();
public Money Total => Lines.Sum(l => l.LineTotal);
public void AddLine(Product product, int quantity, Money unitPrice)
{
var line = new OrderLine(product.Id, quantity, unitPrice);
Lines.Add(line);
// Проверки инвариантов
Validate();
}
private void Validate()
{
// Пример простого инварианта
if (Total.Amount < 0) throw new InvalidOperationException("Total cannot be negative.");
}
}
Существуют два распространённых подхода к реализации агрегатов в архитектуре и persistance:
- Традиционный ORM-ориентированный подход, где агрегат репрезентируется как единое целое в базе данных, и изменения происходят через репозитории, возвращающие полный агрегат.
- Event Sourcing, где каждое изменение агрегата порождает событие: изменение состояния повторно воспроизводится из журнала событий. Это обеспечивает полную историю и гибкость в эволюции модели, но требует сложной инфраструктуры и внимания к совместимости событий.
Доменные сервисы - это поведенческие операции, которые не принадлежат напрямую ни одной сущности или значению объекта, а относятся к доменной области в целом. Они обычно реализуют бизнес-логики, зависящие от нескольких агрегатов, или предоставляют операции, которые слишком громоздки для одного агрегата. Примеры: расчёт цены с учётом сложной скидочной схемы, планирование доставки, расчёт доступной емкости по нескольким складам. Доменные сервисы помогают избегать перегруженности сущностей заботой о бизнес-правилах и улучшают тестируемость модели.
public interface PricingService
{
Money CalculateTotal(IEnumerable lines, Customer customer);
}
С точки зрения архитектуры и владения кодом, следует помнить:
- Сущности хранятся и управляют своей идентичностью и состоянием на протяжении времени; они устойчивы к изменению внешних атрибутов.
- Значения объектов обеспечивают точную и предсказуемую логику равенства и неизменяемость, что упрощает копирование и передачу между контекстами.
- Агрегаты устанавливают границы консистентности и инвариантов, управляя изменениями через корень агрегата.
-
Доменные сервисы инкапсулируют операции, которые требуют координации между несколькими агрегатами или выходят за рамки одного корня.
Архитектурные принципы и правила
- Границы транзакций и агрегации: обновления внутри одного агрегата должны выполняться в рамках одной транзакции, чтобы сохранить инварианты. Внешние вызовы к другим агрегатам стоит выполнять после завершения коммита, либо через события домена.
- Согласованность и интеграционные контракты: внешние контексты и сервисы взаимодействуют через явные контракты (DTO, порт-адаптеры), минимизируя тесную связанность и зависимость от внутренней реализации.
- Единый язык: все участники проекта используют одно и то же словарное ядро - термины сущности, значения объектов, агрегаты и доменные сервисы должны быть отражены в языке как в коде, так и в коммуникациях.
- Эволюция модели: изменения в границах агрегатов должны сопровождаться миграцией данных и совместимостью контрактов; при необходимости - декомпозиция границ контекстов и переопределение агрегатов.
-
Независимость контекстов: интеграции должны быть проектированы через адаптеры и анти-усиливающий слой (Anti-Corruption Layer), чтобы изменения внутри одного контекста не приводили к каскаду изменений в другом.
public interface IAggregateRoot { Guid Id { get; } } public interface IRepositorywhere T : IAggregateRoot { T GetById(Guid id); void Save(T aggregate); } В контексте интеграции важно помнить, что внешние системы часто работают по своим контрактам. В DDD эти контракты моделируются как частично согласованные соглашения между границами контекстов: дата, структура, версии сообщений и правила обработки. При сменах контрактов необходимо поддерживать деградации и обратную совместимость, чтобы не нарушать работу существующих интеграций.
Применение на примерах
Рассмотрим реальный пример доменной области - розничная торговля с онлайн-магазином и складами. В этой предметной области ключевые элементы включают:
- Сущности: Customer, Warehouse, User (оператор, сотрудник магазина).
- Значения объектов: Address, Money, ProductCode, QuantityConstraint.
- Агрегаты: Order (корень), InventorySnapshot (когда нужна глобальная консистентность по складам), Cart (если рассматривать корзину как отдельный агрегат до оформления заказа).
- Доменные сервисы: PricingService (сложная логика цены и скидок), InventoryAllocationService (распределение элементов заказов по складам, резервирование), DeliveryRoutingService (планирование маршрутов доставки).
Давайте рассмотрим упрощённый сценарий: оформление заказа. В рамках агрегата Order мы держим список OrderLine’ов, где каждый OrderLine представляет значение товара (ProductId) и количество. В расчёте итоговой цены применяется PricingService, который учитывает базовую цену, доступные скидки и налоговую составляющую. Внешний вызов к InventoryService проверяет наличие товара и резервирует необходимое количество перед подтверждением заказа - это задача доменного сервиса, который координирует взаимодействия между Order и Inventory.
Ниже упрощённый фрагмент кода, иллюстрирующий координацию между Order агрегатом и PricingService. Этот пример демонстрирует, как доменная модель поддерживается внешними сервисами без нарушения инвариантов самого агрегата:
public class Order
{
public Guid Id { get; private set; }
public IList Lines { get; private set; } = new List();
public Money Total { get; private set; }
public Order(Guid id)
{
Id = id;
Total = new Money(0, "EUR");
}
public void AddLine(Product product, int quantity, Money unitPrice)
{
var line = new OrderLine(product.Id, quantity, unitPrice);
Lines.Add(line);
}
public void RecalculateTotal(PricingService pricing)
{
Total = pricing.CalculateTotal(Lines);
}
}
public class PricingService
{
public Money CalculateTotal(IEnumerable lines)
{
Money sum = new Money(0, "EUR");
foreach (var line in lines)
{
sum = sum.Add(line.Quantity * line.UnitPrice.Amount, line.UnitPrice.Currency);
}
// Применение скидок и налогов
return ApplyDiscounts(sum);
}
private Money ApplyDiscounts(Money amount)
{
// Пример простой логики скидок
// Этот метод может комплексно учитывать промо-акции, клиентский статус и т.д.
return amount; // для простоты без изменений
}
}
Эти примеры показывают принцип: сущности и значения объектов формируют внутреннюю логику агрегатов, а доменные сервисы отвечают за координацию и вычисления, которые выходят за пределы одного агрегата.
Интеграционные аспекты: внешний мир чаще требует доступа к данным через схемы, приведённые к контрактам. Например, система продаж может публиковать события OrderCreated, OrderTotalUpdated, ArchiveOrder и т. д. Эти события могут служить источником для интеграции с ERP, CRM или системами учета. При этом следует избегать прямых ссылок на внутреннюю структуру Order из внешних контекстов - достаточно идентификатора и полезной бизнес-информации в виде DTO или событий.
Практика проектирования агрегатов и контрактов
- Определение корня агрегата происходит на основе бизнес-инвариантов. В примере Order корень агрегата: все изменения заказа проходят через Order и коллекцию OrderLine.
- Вложенные сущности внутри агрегата должны быть подчинены инвариантам корня. Внешние контексты не должны без промежуточной адаптации напрямую модифицировать Lines.
- Значения объектов внутри агрегата должны быть достаточно мелкими и возвращаться как часть состояния, не влияя на внешний контракт. В случае изменений свойств значения, лучше заменить их новой копией объекта.
- Внешние интеграции используют анти-усиливающий слой (ACL) для предотвращения побочных эффектов изменений во внутренней модели.
Ключ к устойчивой архитектуре - сбалансированное разнесение ответственности между сущностями, значениями объектов, агрегатами и доменными сервисами. В рамках вашего проекта важно:
- чётко определять границы агрегатов на основе бизнес-тредов и консистентности,
- поддерживать единый язык и ясную документацию контрактов между контекстами,
-
проектировать доменные сервисы для координации между агрегатами без нарушения изоляции границ.
Интеграционные контракты и управление изменениями
Изменения в модели требуют аккуратной эволюции контрактов между границами контекстов. В практике DDD применяются:
- версионирование контрактов и событий: при изменении структуры события добавляются версии, старые версии продолжают существовать параллельно на поддержке клиентов.
- анти-усиливающий слой: адаптеры, построенные вокруг внешних интеграций, обеспечивают защиту внутренней модели от изменений во внешнем мире и позволяют постепенно переходить на новые формы взаимодействия.
-
миграция данных: при изменении структуры значений объектов необходимо планировать миграции, например, обновление базы данных или преобразование существующих агретатов к новой схеме без потери консистентности.
Управление изменениями в модели
Изменения в моделях доменной области неизбежны по мере роста продукта и изменений бизнес-троек. Правильная практика включает:
- минимизация изменений границ агрегатов без необходимости: сначала оценивается влияние на инварианты и внешний контракт, затем принимается решение об изменениях.
- эволюционный дизайн: небольшие, управляемые изменения, которые можно внедрять поэтапно, без больших миграций.
-
документирование изменений в едином языке и согласование идей между командами: бизнес-ассистенты, разработчики и архитекторы должны работать над одной моделью и одним языком.
Практические реализации и архитектура кода
- ORM vs. чистый доменный слой: для поддержания инвариантов внутри агрегатов применяются паттерны Repository и Unit of Work, чтобы обеспечить консистентность и контроль над обновлениями. В некоторых случаях целесообразно вынести часть поведения в доменные сервисы, чтобы избежать «Anemic Domain Model».
- Моделирование в рамках процесса разработки: проектирование начинается с референсной доменной модели и постепенно обогащается примерами реальных бизнес-сценариев. Важна обратная связь от бизнес-аналитиков и продуктового менеджмента.
-
Примеры интеграций: публикация доменных событий в шине событий и обработчики событий внутри и между контекстами. При необходимости - использование паттернов CQRS для разделения чтения и записи и улучшения масштабируемости.
Key takeaways
- Сущности сохраняют идентичность и эволюцию через время; значения объектов обеспечивают предсказуемость через неизменяемость и определение равенства по значениям.
- Агрегаты устанавливают границы консистентности и инвариантов; корень агрегата служит единственной точкой доступа к состоянию внутри границы.
- Доменные сервисы решают задачи бизнес-логики, требующие координации между несколькими агрегатами или выполнения сложных вычислений.
- Эволюцию модели следует планировать через контрактное управление, ACL-слой и миграции данных, сохраняя совместимость существующих интеграций.
-
Важно интегрировать архитектурные решения с единым языком и практиками, чтобы обеспечить устойчивую трансформацию и внедрение изменений.
FAQ
- Что такое сущность и чем отличается от значения объекта?
Сущность определяется своей уникальной идентичностью; она сохраняется через изменение состояния. Значение объекта не имеет идентичности и определяется по своим атрибутам; любые изменения фактически создают новый экземпляр. В рамках агрегаций сущности и значения объектов работают вместе, но границы и инварианты устанавливаются корнем агрегата.
- Как выбрать, что относится к значению объекта внутри агрегата?
Значения объектов лучше использовать для монада-представления фундаментальных атрибутов, которые должны быть сравнимы по значениям и не требуют уникальной идентичности. Например, деньги, адреса, географические координаты. Если объект требуется уникально идентифицировать или он имеет собственную жизненную историю - его следует рассматривать как сущность внутри или вне агрегата.
- Какие признаки говорят о необходимости агрегата?
Если бизнес-правило зависит от консистентности нескольких элементов, которые должны обрабатываться атомарно (в рамках одной транзакции), это признак того, что следует использовать агрегат. Корень агрегата обеспечивает единую точку доступа и управляет инвариантами.
- Когда стоит использовать доменный сервис?
Доменный сервис полезен, когда поведение затрагивает несколько агрегатов или требует координации между ними, а не относится к одной сущности или значению объекта. Он помогает избежать перегруженности сущностей и сохраняет бизнес-логики под единым управлением.
- Какие риски связаны с агрегациями и их границами?
Слишком крупные агрегаты усложняют управление состоянием и транзакциями; слишком маленькие - ведут к избыточной координации между контекстами. Правило «одна транзакция - один агрегат» часто служит ориентиром, но следует учитывать требования к согласованию в реальном мире и возможность использования событий.
- Как управлять изменениями в модели и контрактами?
Используйте версионирование событий и контрактов, внедрите ACL, планируйте миграции данных. Вносите изменения постепенно, минимизируйте влияние на внешние системы и предоставляйте обратную совместимость там, где это возможно.
- Какие практики документации помогают в работе с терминами DDD?
Единый словарь и схема контекстов не должны быть статичными. Регулярно обновляйте моделирование, отражайте изменения в архитектурной документации и в примерах сценариев бизнес-процессов. Используйте диаграммы агрегатов и визуализации границ контекстов для быстрого восприятия команды.
- Какие язки и технологии полезны в реализации?
Важно сосредоточиться на архитектуре и концепциях: репозитории, единый язык, границы агрегатов. В качестве инструментов можно использовать ORM (например, EF Core или Hibernate) для управления сохранением внутри агрегатов, и обработчики доменных событий для интеграций между контекстами. При необходимости применяйте CQRS и Event Sourcing для сложных сценариев эволюции и аудита.
- Как практично внедрять в командной среде?
Начните с малого: определите несколько ключевых агрегатов, сформулируйте их корни и инварианты, создайте доменные сервисы для координации, настройте простые события для интеграции. Постепенно расширяйте модель, сохраняя обратную совместимость и документируя изменения.
- Какие признаки хорошей реализации в коде?
Четко отделённый доменный слой от инфраструктурного слоя, понятный и устойчивый единый язык, консервативные изменения границ контекстов, тестируемые invariants и сценарии. Применение паттернов Repository, Factory и Domain Service должно быть согласованным и целостным, без излишней сложности.



