Для создания удобного и полезного домена приложения нужно понимать, как использовать корень агрегации (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 домена. По нему практически читаются возможные сценарии использования объектов:

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