176,64
Рейтинг
Dodo Engineering
О том, как разработчики строят IT в Dodo

Агрегаты, мои агрегаты, как приятно о вас думать

Блог компании Dodo EngineeringПрограммирование.NETПроектирование и рефакторинг

В 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 декабря пройдет предновогодний онлайн-митап: будет много кода, общения и веселья. Приходите! Регистрация по ссылке.

Теги:ddddomain-driven designпроектированиепаттерны проектированияагрегатыdodo isdodo pizza engineeringdodo
Хабы: Блог компании Dodo Engineering Программирование .NET Проектирование и рефакторинг
+29
6,1k 58
Комментарии 83

Похожие публикации

Fullstack (.NET) developer
Dodo PizzaМоскваМожно удаленно
Mobile QA automation
Dodo PizzaМоскваМожно удаленно
SRE
Dodo PizzaМоскваМожно удаленно

Лучшие публикации за сутки

Информация

Дата основания
Местоположение
Россия
Сайт
dodo.dev
Численность
101–200 человек
Дата регистрации

Блог на Хабре