В 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.

  • Отличную тестируемость: вы пишите тесты на состояние практически без моков.

  • Понятный контракт и инварианты класса.

Тема агрегатов, конечно же, не исчерпывается этой статьей. В следующих статьях я расскажу о темах сложнее: как выбирать Агрегат, как они взаимодействуют и затрону транзакционные границы.

По теме агрегатов рекомендую почитать:

Эта статья написана по следам воркшопа на конференции Archdays-2020. Запись доступна на YouTube. Для этого воркшопа я подготовил репозиторий с кодом и ссылками.

Если интересно узнать что такое DDD, как использовать или хотите обсудить статью — присоединяйтесь к чату и каналу DDDevotion. 23 декабря пройдет предновогодний онлайн-митап: будет много кода, общения и веселья. Приходите! Регистрация по ссылке.