В Domain-Driven Design выделяют стратегические и тактические паттерны. Например, первые — это Единый язык, а вторые — Агрегаты. Я много раз слышал от коллег, что со стратегией всё понятно, но когда дело доходит до перехода на тактический уровень (до кода) — всё в тумане. Это приводит к некорректным техническим решениям, которые не могут компенсировать даже правильный настрой и близость к бизнесу. Для успеха проекта крайне важно освоить тактические паттерны, особенно Агрегаты. Всё потому, что Агрегаты инкапсулируют в себя почти всю бизнес-логику, это основа вашего приложения. В этой статье я и расскажу про Агрегаты, как они могут помочь и почему важно их освоить. Но...
Антипаттерны
Удачные решения закрепляется как паттерны. Неудачные решения, которые разработчики используют вновь и вновь, закрепляются как антипаттерны.
Анемичная модель
Это первый антипаттерн, с которым я постоянно сталкиваюсь. Типичная анемичная модель выглядит как набор классов, достаточно точно передающих состояние объектов реального мира. Но у этих классов нет поведения, если не считать поведением пачку геттеров и сеттеров. Заполнение полей в такой модели происходит в слое доменных сервисов. По факту, сами модели не владеют своими полями.
//типичная анемичная модель
public class Order
{
public UUId Id {get; set;}
public Product[] Items {get; set;}
public decimal TotalPrice {get; set;}
public Tax[] Taxes {get; set;}
public Address DeliveryAddress {get; set;}
public string Phone {get; set;}
}
Недостатки модели.
У класса нет инвариантов. Объект обычно создается беспараметрическим конструктором, необязательно в консистентном состоянии. В течение жизни поля экземпляра могут меняться в различных местах и нельзя гарантировать осмысленное состояние. Например, можно забыть установить корректное значение TotalPrice
при пересчете налогов, изменении адреса или продуктов.
Жонглирование. Обычно слой доменных сервисов представлен несколькими сервисами и экземпляр класса перебрасывается между сервисами. Каждый сервис меняет часть состояния объекта. Например, есть Order
и OrderService
создает экземпляр в каком-то виде, потом CalculationService
заполняет цены-скидки-налоги, а какой-нибудь DeliveryService
фиксирует информацию о доставке. Часть сервисов может обновлять поля объекта, часть только читать. Но все равно мы получаем высокую связанность сервисов через такие объекты и низкую кохезию (к этим терминам мы еще вернемся).
Как исправить?
Если на проекте много анемичных моделей — начните с «чистки»: уберите публичные сеттеры; уберите беспараметрические конструкторы. Отмечу, что не получится перейти одним махом. Это длительный процесс рефакторинга, не стоит переводить анемичную модель на агрегаты в духе Big Bang.
Прежде чем вносить новую логику на уровне сервиса, подумайте о причинах — можно ли эту логику разместить внутрь класса. Есть отличный пример перехода от анемичной модели к богатой доменной от Kamil Grzybek.
Использование Анемичных моделей для DTO абсолютно нормально.
Универсальная модель
Второй антипаттерн появляется от избыточного переиспользования классов и модулей и попытки построить универсальные классы, которые опишут всевозможные аспекты объектов реального мира. Например, вернемся к нашему Order
.
Когда система развивается, объект получает поля, чтобы отвечать любым новым требованиям. В таком объекте собраны все кейсы какие только могут произойти с Заказом в нашей системе. Получается такая «лестница Эшера»: вроде каждая часть нормальна и полезна, но всё вместе уже с трудом поддается восприятию.
Недостатки антипаттерна.
Каждый раз используется только часть модели. Объект большой, наши сервисы и репозитории заполняют только часть полей, а остальные могут остаться неинициализированными, а объект может оказаться в несоглассованном состоянии.
Непонятно где ждать Null Reference Exception
. (NullPointerException
, AttributeError: 'NoneType' object has no attribute
и т.п.). Из предыдущего пункта следует, что легко можно встретиться с null
. Без просмотра кода сервисов и репозиториев вы не можете сказать какие поля в данном флоу заполняются, а какие нет. Хуже всего, что позже кто-то может чуть «оптимизировать» приложение и перестать заполнять часть полей. Статический поиск использований становится бесполезным инструментом, так как надо проходить прямо по коду.
Много лишних данные в объектах. Например, вы получаете историю заказов для пользователя, и каждый элемент этого массива будет полноценным экземпляром с налогами, продуктами и ингредиентами. Лишние поля могут требовать значительных дополнительных затрат по памяти, трафику, ЦПУ, если такой объект интенсивно используется в нагруженном сервисе.
Нет границ и контракта. Самое плохое, что границы таких моделей начинают плыть. Из-за переизбытка деталей разработчику сложно принять хорошие решения об уместности нового поля в объекте, о подходящем именовании.
Как исправить?
Не пытайтесь строить универсальные модели.
В DDD используется подход с разбиением моделей по Ограниченным контекстам (Bounded Contexts). Несколько лаконичных моделей Order не нарушают принципа DRY. Вы не должны повторять себя именно в поведении, и не бойтесь повторять себя в данных.
Перейдем к Агрегатам.
Агрегат
При использовании DDD принято делить наши доменные классы на Сущности (Entities) и Объекты-значения (Value objects). Они существенно отличаются друг от друга, например, сущности имеют историю, а у Объектов-значений нулевой жизненный цикл. Самое важное отличие между ними — правила идентичности. Более подробно можно почитать в статье @vkhorikov«Entity vs Value Object: полный список отличий».
Агрегат – кластер Сущностей и Объектов-значений, который воспринимается снаружи как единое целое.
Все эти сущности доступны только через Корень агрегата (Aggregate Root). Звучит просто, но непонятно. Покажу на примере — возьмем наш Заказ, у которого помимо других полей есть Налоги и Продукты.
Что мы можем делать:
Как-то получать весь кластер по Id корня Агрегата, или другому набору атрибутов, определяющим идентичность.
Использовать публичные методы корня Агрегата для изменения состояния, в том числе внутренних сущностей.
Избегайте:
Получать экземпляры Tax/Product напрямую, в обход сущности Order.
Выставлять наружу структуру агрегата. Лучше просто выставить набор методов, а внутреннее устройство оставить неизвестным.
//Иногда необходимо выставить часть агрегата, в таком случае можно выставить как readonly-поля
private readonly List<OrderItem> _orderItems;
public IReadOnlyCollection<OrderItem> OrderItems => _orderItems;
Таким образом, внешние потребители не знают ничего об устройстве нашего агрегата (массив у нас под капотом или словарь — неважно!). Такой объект нельзя привести в несогласованное состояние. В случае анемичной модели мы в любой момент можем забыть поменять взаимосвязанные поля синхронно, например, Items
и TotalPrice
. Используя агрегаты мы пишем бизнес-логику в одном месте, можем выставить явные инварианты класса, написать тест.
Примечание: о проектировании по контракту можно почитать в статье «Программирование по контракту в .NET Framework 4» и «Программирование согласно контракту на JVM».
// простой пример агрегата Заказ
public class Order
{
public UUId Id { get; }
private List<Product> _items;
private decimal _totalPrice;
private List<Tax> _taxes;
public Order(UUId id)
{
Id = id;
_items = new List<Product>();
_totalPrice = 0;
_taxes = new List<Tax>();
}
public void AddProduct(Product product)
{
// Агрегат сам может определить можно ли добавить такой продукт
if (!CanAddProduct(product))
{
return;
}
_items.Add(product);
// пересчитываем налоги и общую стоимость
RecalculateTaxesAndTotalPrice();
}
public decimal GetTaxesAmount()
{
return _taxes.Sum(x => x.Amount);
}
private void RecalculateTaxesAndTotalPrice()
{
_taxes = ...
_totalPrice = _items.Sum(x => x.Price) + _taxes.Sum(x=>x.Amount);
}
private bool CanAddProduct(Product product)
{
//some checks
return true;
}
}
Нормотворчество
Казалось бы — пиши код и не обращай ни на кого внимание. Но и у нас есть свои законы. Они, конечно же, не такие обязательные как закон сохранения энергии, но лучше их знать.
Law of Demeter
Закон Деметры или «Не разговаривай с незнакомцами». На вики этот закон поясняют так:
Таким образом, код
a.b.Method()
нарушает Закон Деметры, а кодa.Method()
корректный.
Постойте! Но наши агрегаты как раз и требуют такого написания кода. Правильный агрегат не выставляет наружу поведение своих частей, только своё. Незнакомцы не пройдут! Например, получение суммы налогов будет сделано через метод корня.
private Tax[] _taxes;
public Money GetTaxesAmount()
{
return _taxes.Sum(x=>x.Amount);
}
Чем меньше вы выставили наружу, тем проще рефакторить и развивать. Ведь не надо переделывать потребителей. Так мы снижаем Coupling нашего кода.
Constantine's Law
A structure is stable if cohesion is strong and coupling is low.
Что такое Coupling? Как это связано с Cohesion? Русские переводы очень плохи, особенно когда переводят термины как Связность и Связанность. Я каждый раз воспроизвожу контекст и пытаюсь перевести на английский. Есть ещё вариант перевода: Сцепленность — Coupling и Кохезия — Cohesion. Но он тоже не очень. Буду использовать англоязычные термины.
Coupling — мера взаимозависимости различных классов и модулей друг с другом. При использовании универсальных анемичных моделей и слоя сервисов часто получаем широкое использование доменных классов внутри Сервисов. Что в свою очередь приводит к повышению Coupling.
При использовании агрегатов мы сокращаем пятно контакта (выставляем минимальный контракт наружу) и переносим всю логику внутрь агрегата. У нас пропадает необходимость передавать наш объект между сервисами — Coupling снижается.
Cohesion — мера того, насколько задачи одного программного модуля требуют использования других модулей. Один из плюсов сильной Cohesion — локализация изменений для новой фичи. В случае агрегата вся бизнес-логика обычно локализована в самом агрегате, так мы получаем Strong Cohesion.
Как видим, использование агрегатов позволяет получить Low Coupling и Strong Cohesion.
Заключение
Агрегат – важнейший паттерн в обойме DDD.
При его использовании вы получаете множество преимуществ:
Low Coupling.
Strong Cohesion.
Отличную тестируемость: вы пишите тесты на состояние практически без моков.
Понятный контракт и инварианты класса.
Тема агрегатов, конечно же, не исчерпывается этой статьей. В следующих статьях я расскажу о темах сложнее: как выбирать Агрегат, как они взаимодействуют и затрону транзакционные границы.
По теме агрегатов рекомендую почитать:
Блог Владимира Хорикова, особенно статью Domain model purity vs. domain model completeness.
Опыт рефакторинга описывается в упомянутом примере от Kamil Grzybek, в статье Refactoring from anemic model to DDD. Также есть курс Владимира Хорикова на Pluralsight и исходники к курсу.
Эта статья написана по следам воркшопа на конференции Archdays-2020. Запись доступна на YouTube. Для этого воркшопа я подготовил репозиторий с кодом и ссылками.
Если интересно узнать что такое DDD, как использовать или хотите обсудить статью — присоединяйтесь к чату и каналу DDDevotion. 23 декабря пройдет предновогодний онлайн-митап: будет много кода, общения и веселья. Приходите! Регистрация по ссылке.
pil0t
А как всё это согласуется с SOLID? особенно с S и O?
class Order из примера:
отвечает и за совместимость продуктов и за расчёт налогов, не очень то single
содержит IsValid — который будет регулярно меняться, конфликтует с closed
GraDea Автор
Валидатор и прочие части могут быть представлены отдельными классами, но все равно именно агрегат-рут должен гарантировать соблюдение бизнес-инвариантов в целом.
symbix
OCP не применим для уровня domain, который по сути своей хардкод бизнес-логики. Entities вообще всегда final.