Domain-Driven Design: aggregation root

1 июня 2010 г.

Для создания удобного и полезного домена приложения нужно понимать, как использовать корень агрегации (aggregation root). Это понятие логическое и в коде явным образом нигде не прописываться. В разных пользовательских историях одна и так же сущность домена может быть корнем агрегации и может быть внутри агрегации. На нескольких примерах я покажу принцип работы и цели использования корня агрегации.

Скачать исходный код примеров (git, Visual Studio 2010)

Пользовательская история №1

Создание пользователя

Пользователь может быть создан, если указаны его эл. почты и пароль. У пользователя есть набор ролей. При создании все пользователи имеют роль Member.

Решение

Определяющими характеристиками пользователя является эл. почта и пароль. Значит объект пользователя может быть создан только, когда известны эти данные. Код доменного объекта Account, который показывает эти бизнес-требования:

public class Account
{
 private readonly List<Role> roles;

 protected Account()
 {
  roles = new List<Role>();
 }

 public Account(string email, string password) : this()
 {
  Email = email;
  Password = password;
  IsActive = false;

  AddRole(Role.Member);
 }

 public string Email { get; protected set; }

 public string Password { get; protected set; }

 public IEnumerable<Role> Roles
 {
  get { return roles; }
 }

 public void AddRole(Role role)
 {
  if (roles.Contains(role))
     return;

  roles.Add(role);
 }
}

Можем ли мы сначала создать пользователя, а затем выставить ему необходимые поля? Да, но так мы скроем требование про обязательные данные. Мы не может создать пользователя без эл. почты и пароля по условии пользовательской истории, мы не можем сделать этого и в коде.

Вместо List<Role> Roles {get; set;} используется IEnumerable<Role> {get;} без сеттера. Чем хорош интерфейс IEnumerable? У него нет функции Add. Нам не надо открывать эту функцию вовне доменного объекта Account. Кроме добавления роли в коллекцию, она несет логику проверки роли на уникальность. Таким образом, мы избегаем дублирования этой проверки в других частях системы. Кроме того, добавление роли функцией AddRole, кажется более очевидным, чем добавление роли в коллекцию Roles напрямую.

Пример использования

using (unitOfWorkFactory.Create())
{
 var account = new Account("email", "password");

 account.AddRole(Role.Admin);

 accountRepository.Save(account);
}

Пользовательская история №2

Создание клиента

Зарегистрированный пользователь может стать нашим клиентом. Клиент входит в систему с помощью введенных ранее эл. почты и пароля с дополнительной ролью Client.

Решение

Раз клиент не может быть создан без пользователя, то отразим это в коде класса Client:

public class Client
{
 private readonly List<Order> orders;

 protected Client()
 {
  orders = new List<Order>();
 }

 public Client(Account account) : this()
 {
  if (account == null)
   throw new ArgumentNullException("account");

  Account = account;
  Account.AddRole(Role.Client);
 }

 public Account Account { get; protected set; }
}

Целью такого решения является сохранить целостность домена. Это дает нам два преимущества. Первое, правила бизнес-логики явным образом отражены в коде. Второе, такой способ создания объектов накладывает ограничения и делает затруднительным написание кода против бизнес-правил.

Пример использования

using (unitOfWorkFactory.Create())
{
 Account account = accountRepository.GetByEmail("email");

 var client = new Client(account);

 clientRepository.Save(client);
}

Пользовательская история №3

Создание заказа

Клиент может выбрать несколько продуктов и оформить заказ.

Решение

Реализуем по той же логике:

public class Order
{
 private readonly List<OrderHistoryEntry> historyEntries;
 private readonly List<Product> products;

 protected Order()
 {
  products = new List<Product>();
 }

 public Order(IEnumerable<Product> products, Client client) : this()
 {
  if (products == null)
   throw new ArgumentNullException("products");

  if (client == null)
   throw new ArgumentNullException("client");

  Client = client;
  products = products.ToList();
 }

 public Client Client { get; protected set; }

 public IEnumerable<Product> Products
 {
  get { return products; }
 }

 public void AddProduct(Product product)
 {
  product.Order = this;
  products.Add(product);
 }
}

Пример использования

using (unitOfWorkFactory.Create())
{
 Client client = clientRepository.Get(23);

 Product product1 = productRepository.Get(1);
 Product product2 = productRepository.Get(2);

 var products = new List<Product> {product1, product2};

 var order = new Order(products, client);

 orderRepository.Save(order);
}

Пользовательская история №4

Подтверждение заказа

После проверки менеджер может подтвердить заказ. При этом должна сохраняться дата подтверждения.

Решение

Это более интересная ситуация, потому что она затрагивает несколько сущностей домена. В этой пользовательской истории будет участвовать Order и OrderHistoryEntry:

public class Order
{
 //...

 public bool IsApproved { get; protected set; }

 public IEnumerable<OrderHistoryEntry> HistoryEntries
 {
  get { return historyEntries; }
 }

 public void Approve()
 {
  IsApproved = true;

  var orderHistoryEntry = new OrderHistoryEntry(this, OrderHistoryType.Approved);

  historyEntries.Add(orderHistoryEntry);
 }
}

Пример использования

using (unitOfWorkFactory.Create())
{
 Order order = orderRepository.Get(1000);

 order.Approve();
}

Изюминка в том, что мы используем один метод Approve в классе Order, скрывая остальную логику. В этой пользовательской истории корень агрегации - это класс Order. Внутри этой агрегации будет доменный объект OrderHistoryEntry.

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

История использование №5

Блокировка клиента

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

Решение

public class Client
{
 //...

 public void Lock()
 {
  IEnumerable<Order> notApprovedOrders = orders.Where(o => o.IsApproved == false);

  foreach (Order order in notApprovedOrders)
   orders.Remove(order);

  Account.Disable();
 }
}

Пример использования

using (unitOfWorkFactory.Create())
{
 Client client = clientRepository.Get(1000);

 client.Lock();
}

Приведу метафору применения корня агрегации: "Чтобы повернуть колеса автомобиля мы поворачиваем руль, не задумываясь об остальных деталях: сцеплениях, шестеренках и т.д. В данном случае руль - это корень агрегации".

API домена

Самое приятное в этой истории то, что у нас получается красивый API домена. По нему практически читаются возможные сценарии использования объектов:

Общий подход

  1. Передаем обязательные поля в конструктор, скрываем пустой конструктор. Запрещаем менять обязательные поля напрямую через сеттеры.
  2. По возможности убираем вообще все публичные сеттеры
  3. Меням IList (ICollection) на IEnumerable
  4. Используем корень агрегации данной истории использования для работы со всем графом объектов
  5. Бизнес-логику связанную с доменом оставляем внутри домена
  6. Сохраняем в репозитории только корень агрегации

Ссылки

Strengthening your domain: Aggregate Construction

Domain-Driven Design in an Evolving Architecture

Моя книга «Антихрупкость в IT»

Как достигать результатов в IT-проектах в условиях неопределённости. Подробнее...