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

90 комментариев:

  1. Александр, в очередной раз радуешь своими статьями! Спасибо!

    ОтветитьУдалить
  2. @Wanderer
    Быстро же ты ее прочитал :)

    ОтветитьУдалить
  3. На самом деле я сначала написал коммент, а сейчас читаю с огромным удовольствием))))

    ОтветитьУдалить
  4. весьма интересно! есть над чем поразмыслить - всегда, когда читаю твои статьи, невольно сравниваю твой код и свой - это помогает мне учиться новому и отказываться от старых костылей ;)

    ОтветитьУдалить
  5. Походу прочтения возник вопрос

    для чего используется следующая конструкция в примерах использования доменных классов:

    using (unitOfWorkFactory.Create())
    {
    ...
    }

    а так все очень хорошо написано, "простенько и со вкусом" :)

    ОтветитьУдалить
  6. Александр, спасибо за статью, но в процессе чтения возникли несколько вопросов, самый главный из которых заключается в следующем.

    Давайте рассмотрим класс Account и его неформальную спецификацию, выраженную в виде следующего предложения:

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

    Исходя из этой спецификации предполагается наличие неявного инварианта сущности Account, которая сводится к обязательному наличию двух полей: электронной почты и пароля. Но код класса Account этого не гарантирует. Наличие защищенного конструктора без параметров, говорит читателю вашего кода (в данном случае мне), что такого инварианта в этом классе нет. Ведь код не налагает никаких ограничений на то, чтобы я не смог нарушить инвариант базового класса путем создания наследника и по недосмотру (или злому умыслу) вызвать именно этот конструктор, привнеся хаос в стройную иерархию наследования.

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

    Я конечно не предлагаю делать все классы sealed, но добавлять защищенные методы (как конструкторы, так и setter-ы), мне кажется нарушением принципов KISS, да и многих других практик, которые не советуют играть нам с вами в экстрасенсов и предугадывать будущее поведение, вместо этого создавать сущности, которые решают одну текущую задачу, но при этом делают это хорошо.

    ОтветитьУдалить
  7. Спасибо за статью.
    В UserStory 4 непонятный момент
    public void Approve()
    {
    IsApproved = true;

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

    historyEntries.Add(clientHistoryEntry);
    }

    откуда взялось clientHistoryEntry? Возможно имелось в виду orderHistoryEntry

    ОтветитьУдалить
  8. @Wanderer
    Это создание UnitOfWork, с которым будут работать все обращения к БД внутри фигурных скобок.

    ОтветитьУдалить
  9. @Сергей Тепляков
    Тут дело не в инвариантах и защите от программистов. Просто так более удобно работать с доменом.

    ОтветитьУдалить
  10. @Сергей Тепляков
    В основном конструктор по умолчанию защищенный нужен для NH. Мы используем NHibernate как наиболее удобную ORM, т.к. она практически не налагает ограничений на сущности, и, следовательно, можно безболезненно отражать данные из базы прямо на доменные объекты.

    Если вам так не хочется использовать этот конструктор - пометьте его [Obsolete("Don't use this", true)], тогда компилятор будет генерировать ошибку при использовании эго.

    ОтветитьУдалить
  11. Что если обязательных полей значительно больше? скажем 8-10. конструктор превратится в кашу.
    да и маппиг NH проще будет без обязательных полей.
    для проверки обязательности есть понятие валидация. btw, недавно был не плохой пример реализации валидации на этом сайте.

    ОтветитьУдалить
  12. @Dmitry Sukhovilin
    > Что если обязательных полей значительно больше?

    Можешь привести пример такой сущности?

    ОтветитьУдалить
  13. Что вспомнил "сходу" - в проекте моем есть модель Payment.
    Обязательные поля

    1 поле - наличие подтверждения платежа
    1 поле - ссылка на контракт (за что платим)
    1 поле - огранизация (кто платат)
    1 поле - дата платежа
    2 поля - период действия
    2 поля - ответственный менеджер, ответственный кассир (точно не помню как называются)

    + еще что 2-3 поля не могу вспомнить, в проект лезть лень :)

    и я думаю, это далеко не максимальный пример.

    Всеравно валидация нужна, так зачем "дублировать" ее по коду?

    ОтветитьУдалить
  14. @Dmitry Sukhovilin
    Как может "наличие подтверждения платежа" быть при создании платежа?

    "дата платежа" это сегодняшний день? Если да, то зачем передавать ее явно?

    Наличие БОЛЬШОГО кол-во параметров в конструкторе может быть сигналом к нарушению принципа единственности ответственности.

    > Всеравно валидация нужна, так зачем "дублировать" ее по коду?

    Еще раз повторю, что это не валидация, а написанное в коде бизнес-правило.

    В вашем случае: "Организация может производить платеж. При этом назначается ответственный кассир."

    ОтветитьУдалить
  15. > "дата платежа" это сегодняшний день? Если да, то зачем передавать ее явно?

    что бы не раскидывать логику по коду/по слоям

    > Еще раз повторю, что это не валидация, а написанное в коде бизнес-правило.

    оба подхода имеет один смысл. сделать поля обязательными. мне кадется что решение с такими конструкторами менее гибко, а если изменится бизнесс требование то придется изменить конструктор, т.е. нужно затронуть
    - маппиг
    - модель
    - валидатор

    в случае не использования такого конструктора
    - маппинг
    - валидатор

    ОтветитьУдалить
  16. По-моему совершенно неуместно вызывать unitOfWorkFactory.Create() в слое бизнес логики. Вы сказали что данные у вас хранятся в бд, но какая разница BLL'у, где у нас хранятся данные? Весь этот процесс (шаманство с сессиями хибернейта например) должен быть скрыт в конкретной реализации репозитория.

    ОтветитьУдалить
  17. 2 headachy

    а где видно, что UoW вызивается в бизнесс логике ?

    ОтветитьУдалить
  18. @headachy
    А как тогда узнать границы начала и завершения транзакции? Удобнее выставлять ее извне.

    ОтветитьУдалить
  19. @Dmitry Sukhovilin

    по коду видно, что он вызывается из BLL.

    @Александр Бындю

    для транзакций есть TransactionScope, почему бы не использовать его?
    Тут ситуация такая, я читаю ваш код написаный по всем традициям DDD и вдруг вижу что в BLL, встречается UoW, сразу вопрос что это? зачем? ах это оказывается транзакции, ну так я это пойму только прокопав глубже в сторону реализации UoW.

    ОтветитьУдалить
  20. @headachy
    UoW подразумевает атомарность изменений данных. В случае нашего проекта он использует сессию NH, в вашем может быть TransactionScope. Главное, что мы явно объявляем начало UoW.

    Вообще в исходниках я написал, что весь код вокруг сборки Domain нужен только для демонстрации.

    ОтветитьУдалить
  21. Александр, а как ты маппишь IEnumerable в NHibernate?

    ОтветитьУдалить
  22. @ankstoo
    Примерно так:


    HasMany(x => x.Orders)
    .Access.ReadOnlyPropertyThroughLowerCaseField()
    .Cascade.AllDeleteOrphan()
    .Inverse()
    .AsSet();

    ОтветитьУдалить
  23. Правильно ли я понимаю, что нужно брать экземпляр репозитория, передавать ему доменный объект, и выполнить метод .Save() к примеру?
    Или можно вызвать метод .Save() у доменного объекта, в котором экземпляр репозитория сам сохранит изменения в БД?

    ОтветитьУдалить
  24. @s-stude

    Объект не должен ничего знать про сохранение. Тем более он не должен уметь сохранять сам себя http://blog.byndyu.ru/2009/10/blog-post.html

    Лучше передавай доменный объект в репозиторий на сохранение.

    В случае NHibernate, сессия сама отслеживает изменения с объектом и сохраняет их в БД.

    ОтветитьУдалить
  25. Да, объект не будет знать про сохранение, в его конструктор только будет передаваться репозиторий, который будет срабатывать, если у объекта вызвать метод .Save()

    На самом деле я имею вот что:
    Book book = new Book(title, author, bookRepository);
    book.Save();

    а в методе Save уже:
    {
    _repository.Save(book);
    }

    Или же нужно так:

    Book book = new Book(title, author, bookRepository);
    _bookRepository.Save(book);

    Вот вопрос, как и почему так, а не иначе?

    ОтветитьУдалить
  26. @s-stude

    Второй вариант более предпочтительный.

    Почему не первый? Нам не нужна связь доменного объекта с конкретным репозиторием. Если это будет интерфейс, то придется делать инжектирование в конструктор доменного объекта. И третье, сохранение это не обязанность доменного объекта, он не должен быть наделен такой функцией.

    ОтветитьУдалить
  27. Александр Бындю комментирует...

    @Сергей Тепляков
    Тут дело не в инвариантах и защите от программистов. Просто так более удобно работать с доменом.


    Как-то не совсем понятно что имел ввиду автор этой фразой. Аргументы Сергея более чем понятны, хотелось бы такой же понятный ответ услышать.

    Автор несомненный гик и позабавило это - приводить примеры на DDD с 3-мя сущностями - (epic fail) + аргументация в стиле "удобно работать с доменом". Если залез в DDD как гик, то будь последователен и не думай об удобстве - будь гиком до конца - DDD априоре сложен и неудобен.

    Наборы из 3-х сущностей и 5-ти операций подходят под паттерны transactional script-tabular module, а никак не под самый сложный Domain Model. Соответственно чтобы сделать убедительный пример (а не программазм ради программазма) использования этого паттерна - нужно написать нехилый такой труд с реальным примером из реальной жизни, а не очередную кастрированную поделку в стиле Northwind-AdventureWorks. Но видимо блоггеры из Челябинска настолько суровы, что применяют DDD даже на лабораторных работах на 1-м курсе.

    ОтветитьУдалить
  28. @roger_c
    Приведи пример статьи по программированию, где приводится код "из реальной жизни".

    ОтветитьУдалить
  29. это ж троллинг был, чего так серьезно отвечать :)

    ОтветитьУдалить
  30. Как мне уже надоело писать этот банальный пример, но все же…

    Магазин: покупатели (1-*) заказы (1-*) позиции заказов (*-1) товары.
    В скобках указаны кардинальности связей.
    Юзкейсы:
    1)Покупатели должны видеть свои заказы с суммой
    2)Менеджеры должны видеть отчет по продажам (суммы) за день в разрезах по товарам и покупателям
    3)Кладовщики должны видеть количество проданных товаров за период, чтобы успевать заказывать новые

    Что из этих четырех сущностей станет «корнем аггрегации».
    Денормализацию в модель не вносить, все должно быть в 3НФ.

    ОтветитьУдалить
  31. @gandjustas
    Для разных юзкейсов разные корни:
    1) покупатели
    2) заказы
    3) товары

    ОтветитьУдалить
  32. Как это в коде будет?
    Придется создавать 3 разных класса, которые оперируют одними и теми же сущностями?

    ОтветитьУдалить
  33. @gandjustas
    Не 3, а 4: покупатель, заказ, позиция заказа, товар.

    Корень агрегации - это не отдельный объект. Это один из объектов домена, который логически является корнем агрегации.

    ОтветитьУдалить
  34. Ну так покажи как в коде будет выражаться то что объект является aggregation root

    ОтветитьУдалить
  35. @gandjustas
    Вся статья - это пример того, как выглядит корень агрегации :)

    ОтветитьУдалить
  36. Судя по коду из статьи корень агрегации загружается из базы сразу со всеми связанными сущностями.

    Теперь рассмотрим юзкейсы:
    >Покупатели должны видеть свои заказы с суммой

    Те с покупателем загружаются заказы и позиции (чтобы сумму посчитать). (1)

    >Менеджеры должны видеть отчет по продажам (суммы) за день в разрезах по товарам и покупателям
    С заказом загружаются все позиции, товары по всем позициям, и покупатели. (2)

    >Кладовщики должны видеть количество проданных товаров за период, чтобы успевать заказывать новые
    С товаром загружаются все позиции с заказами (чтобы дату получить)

    А учbтывая что все это один и те же сущности, то вся база вытягивается при первом же запросе.

    Например запрашиваем мы покупателя, с ним тянутся заказы и позиции (из 1), с заказами тянутся торвары (из 2), с товарами тянутся все связанные позиции и заказы (из 3), а с заказами тянутся покупатели (из 2), а потом циклично для всех покупателей.
    Вот и приехала к нам в память вся база.

    Можно Lazy Load задействовать чтобы отложить этот фейл, но тогда возникает проблема Select N+1, привязанности сущностей к ОРМ и прочие гадости.

    ОтветитьУдалить
  37. @gandjustas
    Нет, вы можете загружать его с любым уровнем вложенности.

    > А учbтывая что все это один и те же сущности, то вся база вытягивается при первом же запросе.

    Если вам надо посчитать сумму заказов, то совсем не обязательно загружать их все из базы. Можно написать запрос на сумму с помощью функции Sum() в Linq.

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

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

    ОтветитьУдалить
  38. >Нет, вы можете загружать его с любым уровнем вложенности.
    А где мне это указать? И как методы внутри сущностей узнают что какая-то связанная коллекция не загружена?

    >Если вам надо посчитать сумму заказов, то совсем не обязательно загружать их все из базы. Можно написать запрос на сумму с помощью функции Sum() в Linq.
    Отлично, где это писать? Внутри domain object нельзя, там нету ссылки на ORM.

    >Корни агрегации нужны для логического разделения ответственности в предметной области. Т.е. создание корней агрегации помогает создать предметную область максимально приближенную к требуемой.
    Это как-то должно отражаться в коде, иначе зачем было все это писать.

    ОтветитьУдалить
  39. @gandjustas
    > А где мне это указать?
    Любая ORM это умеет делать. Например, в NHibernate есть метод Expand.

    > И как методы внутри сущностей узнают что какая-то связанная коллекция не загружена?

    Опять же это реализовано во всех ORM.

    > Отлично, где это писать?
    В объектах Repository, либо в Query (если используется шаблон CQRS). Второй вариант предпочтительней.

    > Это как-то должно отражаться в коде

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

    > иначе зачем было все это писать

    Понимание корней агрегации в вашем домене помогает построить систему с API наиболее приближенном к потребностям предметной области. Об этом вся статья.

    Вы пробовали выделить корни агрегации в вашем проекте?

    ОтветитьУдалить
  40. >Любая ORM это умеет делать. Например, в NHibernate есть метод Expand.
    Это понятно. Меня интересует какая часть приложения отвечает за загрузку связанных сущностей. Где принимается решение.

    >> И как методы внутри сущностей узнают что какая-то связанная коллекция не загружена?
    >Опять же это реализовано во всех ORM.
    Опять же непонятно как сами сущности об этом узнают. Нету у них явной ссылки на ОРМ.

    >Вы пробовали выделить корни агрегации в вашем проекте?
    Конечно пробовал. Сразу же уперся в вопросы загрузки данных.

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

    Только такая абстракция чрезвычайно дырява с точки зрения реализации.
    1)"Руль" должен непременно грузиться со всей "машиной". При этом руль не должен сам управлять загрузкой данных, он не должен иметь явной ссылки на DAL. В итоге вся работоспособность руля зависит от того в каком контексте он применяется.

    2)В зависимости от ситуации корни агрегации могут меняться, при этом то что написано в 1) никто не отменял, сущности-то те же.

    3)БД обладает сильной связностью и не может быть разбита на несколько непересекающихся подмножеств, то учитывая 1) и 2)можно получить вытягивание всей базы разом.

    Способов решения проблемы два:
    1)Lazy Load, но это плохо, слишком много неявныях связей создает. Кроме того проблему SELECT N+1 никто не отменял.

    2)Явная загрузка связных сущностей, но тогда методы внутри сущностей могут отваливаться потому что связанные коллекции не загружены. И никакими тестами domain model не покроешь такие приколы.
    Решается это таким образом, что код, обрабатывающий связные сущности переезжает в методы, где происходит загрузка. В итоге в самих domain objects остаются только только методы работы с полями этого объекта, а в них пользы не так уж много.
    И получается что не только Aggregation Root идут лесом, но и весь DDD, так как код помещается в хелперы\сервисы.

    ОтветитьУдалить
  42. @gandjustas
    У меня возникло ощущение, что вы не использовали ORM в своих коммерческих проектах. Если использовали, то какую?

    Доменные сущности не отвечают за загрузку данных. Как уже писал выше, выбор данных из БД должен быть реализован в объектах Repository, либо в Query (если используется шаблон CQRS). Второй вариант предпочтительней.

    Опять же у нас с этим проблем никаких не возникает. Опишите проблему в своем проекте, с которой вы столкнулись.

    ОтветитьУдалить
  43. Linq2SQL, EF, BLToolkit
    Даже сам писал еще на Delphi

    >Опять же у нас с этим проблем никаких не возникает

    Ну так я наисал юзкесы, как их в DDD реализовать?

    ОтветитьУдалить
  44. @gandjustas
    На самом деле в этих юзкейсах нет ничего сложного или необычного. Я бы даже сказал, что они вполне стандартные.

    Я предлагаю вам самому попробовать их реализовать. Если вы столкнетесь с проблемами, то присылайте код и описание проблемы мне на почту alexander.byndyu@gmail.com или в гугл-группу http://groups.google.ru/group/dotnetconf. В этой группе есть много разработчиков, которые уже используют DDD. Мы поможем вам справится с проблемами.

    Код мы выкладываем на pastebin.com.

    ОтветитьУдалить
  45. Не целое приложение лень писать, а по памяти восстанавливать все куски где возникли проблемы нереально.

    Но общую суть проблем я достаточно хорошо понимаю. И уже её описал. И даже решения проблемы знаю, но одно плохое, а другое ведет к не-DDD.

    Собственно я столкнувшись с проблемой в первый раз забил на DDD как на способ дизайна кода и теперь пишу так как мне удобно.

    ОтветитьУдалить
  46. @gandjustas
    Дело ваше. Я предложил свою помощь. Если захотите ею воспользоваться, то вы знаете, что делать.

    ОтветитьУдалить
  47. Саш, вот такой вопрос: пример 3, добавление заказа. Я так понимаю, в этом случае никакой дополнительной бизнес логики не предвидится. А что если в момент добавления заказа нужно проверить, если это, к примеру, 100-й заказ клиента, то сделать скидку. Я так понимаю, в этом случае код бы выглядел как-то так:
    client.AddOrder(order);//?
    А уже в этом методе шла бы проверка (бизнес-логика). При этом в объект сlient должны были быть подгружены все orders (и другие нужные для проверки объекты и коллекции).
    Соответственно, еще один вопрос. Понятно, что, скажем при удалении order (если нужна БЛ) нужно вызывать client.DeleteOrder(order); А как быть в случае удаления самого client? Вызывать по очереди все client.DeleteOrder(order), а потом уже удаление самого client, или как-то иначе организовывать?

    ОтветитьУдалить
  48. @Захар

    > Я так понимаю, в этом случае код бы выглядел как-то так

    Да, если проверку на 100-ый заказ можно сделать внутри метода AddOrder, то лучшее ее там и делать. Если проверка требует внешних данных, других сущностей и т.п., то ее надо вынести в сервис.

    > При этом в объект сlient должны были быть подгружены все orders

    Если все делается в едином UoW, то они подгрузятся без проблем. Или вы опасаетесь выборки n+1?

    > А как быть в случае удаления самого client?
    В случае с NHibernate это настраивается в маппингах через Fluent-интерфейс. Вот пример:

    HasMany(x => x.Orders) .Cascade.AllDeleteOrphan()
    .Inverse();

    ОтветитьУдалить
  49. Спасибо. Мне еще пока не знаком UoW (хотя слышал и примерно представляю). Использую Linq2Sql

    ОтветитьУдалить
  50. @Захар

    Можете подробное описание Unit Of Work с исходным кодом посмотреть в статье Совершенный код №2. Пример реализации Unit Of Work с NHibernate

    Linq2Sql генерирует код по БД, а это черевато :) Я бы вам рекомендовал NHibernate 3 или EF 4 (code-first)

    ОтветитьУдалить
  51. @Александр Бындю
    переписывать неохота и некогда :)) Если только какой-то новый проект

    ОтветитьУдалить
  52. У меня такой вопрос: как идентифицировать объекты агрегата за пределами домена, ведь не всегда к ссылкам объектов есть доступ за пределами домена. Можно для этого приспособить инвариант агрегата(например: по инварианту имена уникальны, можно использовать имя для идентификации объекта), а можно просто тупо использовать Id. Как лучше. Наведите на путь истинный пожалуйста!

    ОтветитьУдалить
  53. Алексей, что означает фраза "за пределами домена"?

    ОтветитьУдалить
  54. Мы "моникерами" объектов пользуемся; при этом моникер необязательно = Id, это просто способ навигации. Например, есть конкретный документ "НК-0900", вот для его дочек генерятся номера строк, и потом моникер строки выглядит так, например: "НК-0900 :: 11"

    ОтветитьУдалить
  55. а можно чуть более развёрнуто для чего это нужно и как оно должно работать?

    ОтветитьУдалить
  56. Все решил проблему. Всем спасибо. Обошёлся без id.

    ОтветитьУдалить
  57. @Алексей

    Раз уж решил проблему, то делись решением :)

    ОтветитьУдалить
  58. @Александр
    Хорошая статья, но непонимание в комментариях меня смущает.

    >> У меня возникло ощущение, что вы не использовали ORM в своих коммерческих проектах.
    Я использую ORM к коммерческих проектах уже лет 8. Не понимаю, почему ты не понимаешь суть вопроса gandjustas :)
    Попробую написать user story попроще, на основе первой из статьи - повысить дать права админа аккаунту, зная его email. Да, понятно как написать код:

    // в сервисе
    Account account = this.Repository.All().WithEmail(newAdminEmail).LoadWith(acc => acc.Roles);
    account.AddRole(Roles.Admin);

    И, насколько я понял, предполагается что:
    - код этот будет написан в сервисе, а не внутри класса Account (т.к. есть обращение к репозиторию, а у нас PI)
    - Lazy Load мы не используем из-за страха n+1

    Проблема в том, что работоспособность AddRole зависит от наличия в вызывающем коде куска "LoadWith(acc => acc.Roles)". Неприятно, но не критично. Если я ошибся, и код должен выглядеть по-другому - напиши как именно, пожалуйста.

    Ок, отлично. Во что эта мелкая неприятность выливается в моей предметной области - Task Tracking/Project Management. Есть сущность Task. У него есть -StartDate. Смена StartDate может привести к пересчету StartDate/EntDate/Duration/Progress у любого другого Task-а в проекте, в зависимости от зависимостей (гм) и иерархии. Тасков в проекте - до 2-3 тысяч. Ок, таск - корень. Пишем код сервиса:

    void ChangeStartDateForTask(int taskId, DateTime newStartDate)
    {
    using (SomeUnitOfWork)
    {
    var task = repository.All.LoadWith(????).ByID(taskId);
    task.SetStartDate(newStartDate);
    }
    }

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

    ОтветитьУдалить
  59. @Захар в Linq2SQL - DataContext - это и есть UoW.
    Официальный FAQ так говорит, и у меня нет причин ему не верить :) http://social.msdn.microsoft.com/forums/en-US/linqprojectgeneral/thread/3ae5e457-099e-4d13-9a8b-df3ed4ba0bab/

    ОтветитьУдалить
  60. @Александр, проблема была в понимании вопроса. В DDD я новичок. В моём случае id для объектов оказался не нужен.

    ОтветитьУдалить
  61. Александр, пара вопросов:
    1. Почему Save не в UnitOfWork
    2. Почему нет вызова UnitOfWork Commit/SaveChanges? UnitOfWork комитится автоматически?

    ОтветитьУдалить
  62. @Idsa

    > 1. Почему Save не в UnitOfWork

    Когда писал этот пост идеологически Save было в репозиториях. Сейчас я изменил мнение по этому вопросу и перенес Save в UnitOfWork

    > 2. Почему нет вызова UnitOfWork Commit/SaveChanges? UnitOfWork комитится автоматически?

    Он есть, просто в примерах не вызывается. Если до Dispose не было Commmit, то автоматически делается Rollback.

    ОтветитьУдалить
  63. @Александр, то есть коммит отложенный? А если мне нужно на каком-то этапе закоммитить UnitOfWork (например, для получения автосгенерированного Id), а потом продолжить?

    ОтветитьУдалить
  64. @Idsa

    Можно пример хотя бы приближенный к практике?

    ОтветитьУдалить
  65. @Александр
    Не обратил внимания на дату топика :)
    И все же - есть ли нормальное решение загрузки данных при отсутсвии Lazy Load? Например, как корректно загрузить HistoryEntries в примере использования для истории №4?

    В этот раз я не троллю, мне действительно хочется увидеть решение, т.к. наболело :(

    ОтветитьУдалить
  66. @Pasha

    Может и есть, но я их не видел. Мы пришли к тому, что в Repository или Query создается специальный метод, который не просто берет по ID, а еще подгружает связанные коллекции.

    Ну и конечно спасает Lazy Load. У вас ORM не позволяется LL делать?

    ОтветитьУдалить
  67. > И все же - есть ли нормальное решение загрузки данных

    я делал относительно просто.

    1 GenericRepository на все сущности.

    На сущность можно было навесить аттрибуты, указывающие, какие поля догружать. Репозиторий по этому аттрибуту формировал запрос с необходимыми Include. всё.

    ОтветитьУдалить
  68. @Gengzu

    А если в разных случаях надо подгружать разные связанные коллекции?

    ОтветитьУдалить
  69. было 2 случая - это подргузка 1 элемента с его связими, и подгрузка этих элементов в виде коллекции. в последнем случае могли требоваться не все связи. либо вообще не требоваться.

    касательно других разных случаев, нужно смотреть логику.
    возможно имеет смысл выделить больше Aggregation Root'ов.
    но обычно, если нужна сущность, она нужна целиком.

    ОтветитьУдалить
  70. @Александр

    >> У вас ORM не позволяется LL делать
    хорошая шутка :)

    сейчас практически любой ORM позволяет делать LazyLoad, не в том вопрос.
    От LL обычно отказываются из соображений производительности/предсказуемости. LL в моем случае не спасет, наоборот - делает все еще хуже. Да, я специально проверял :(

    Посмотри мой пример выше, про задачи - там LazyLoad почти сразу или выдает N+1, или загружает пару тысяч задач в память.

    Да, мы тоже пришли к методу LoadWith для Query. Вопрос в том, что вызывать этот специальноый метод должен сервис, а не класс сущности. Просто попробуй переписать пример #4 для случая без LazyLoad, хотя бы псевдокодом - получишь зависимость реализации метода Approve от конкретной реализации вызывающего его кода, что явно нехорошо.

    Ручное управление загрузкой обычно дает 2-3 запроса по 5-10 задач в том же случае. Проблема собственно в том, что построить эти 2-3 запроса можно только посередине пересчета. Т.е. делать пересчет методом класса Task уже нельзя - Task же не должен выбирать данные.

    Сейчас у нас проблема решена переносом пересчета в сервис. Но предметная область у нас такая, что практически вся бизнес-логика натыкается на ту же проблему. И мы получаем выбор:
    - DDD, но медленный
    - TS, но быстрый. Можно назвать его anemic domain, но это будет самообманом.

    Т.к. клиентам как бы не важно DDD или TS, то они голосуют рублем за быстрый вариант.

    Опять получился ответ-полотно, постораюсь коротко: есть конкретные технические проблемы с текущим подходом к DDD в C#. Хотелось бы увидеть проект с логикой сложнее замыленных Order/Customer/Product/OrderItem, использующий DDD, и не имеющий проблем с производительностью. А то пока встречаются только монстры вроде: http://microsoftnlayerapp.codeplex.com/. Да, в нем тоже Order/Customer/Product.

    ОтветитьУдалить
  71. @Gengzu
    Ок, отличное решение. К сожалению, очевидное, естественно, опробованное и отброшенное уже давно. Т.к. моем случае грузит в память около 5000 объектов для сдвига какой-то жалкой даты.

    Есть ли нормальное решение загрузки данных не для магазина с тремя заказами, а для моего конкретного случая с задачами?

    ОтветитьУдалить
  72. это 5000 вложенных объектов? сделайте из них Aggregation Root и грузите отдельно. тогда необходимый объект будет чист. ну и никто не мешает загрузить его без связных сущностей, при необходимости.

    ОтветитьУдалить
  73. @Gengzu

    Вы мое описание проблемы выше читали?

    Есть проект. В проекте 2-3 тысячи задач. У задач есть иерархия и зависимости. Изменение даты начала/конца у одной задачи переводит к пересчету дат вверх/вниз по иерархии, и по зависимостям.

    Окуей, загрузил я задачу, у которой поменялась дата, без связных сущностей - и что толку? Что мне с ней делать - логика пересчета не отработает без загруженных parent/children для нее.

    Сделал загрузку всех загрузку parent/children - ес-но вытянул сразу все задачи, и dependency для них - медленно, и на выходе 5000 объектов в памяти. Опять плохо.

    Сделал LazyLoad - получил 200 запросов в базу. На смену какой-то жалкой даты.

    Забил на DDD, политкоретнее - принял решение об использованиии anemic domain model, написал логику пересчета в сервисе - заработало как надо.

    Есть варианты, не приводящие к скатыванию в Transaction Script?

    ОтветитьУдалить
  74. Варианты следующие:
    1. Провести более детальный анализ требований
    2. Искать недостающий агрегат, который будет управлять расписанием (если это приемлимо - я не знаю всех тонкостей вашего домена)
    3. Распараллелить процесс.
    Строго говоря, процессы изменения даты у одной задачи и у связанных с ней мало связаны и их можно делать асинхронно. Логика такая - у задачи меняется дата, что провоцирует событие StartDateChanged. На это событие подписана сага, которая в обработчике события получает список всех зависимых задач и отсылает команду о пересчете сроков для КАЖДОЙ! задачи. Благодаря этому процесс масштабируется на неограниченное количество машин и потоков.

    ОтветитьУдалить
  75. @xelibrion
    Разве п.3 не идентичен Lazy Load? Единственное что, он будет асинхронным. Кстати в этом случае решение есть даже при сложных и разношерстных связях между задачами.
    @Pasha
    Anemic domain это и есть Transaction Script.

    ОтветитьУдалить
  76. @Мурадов Мурад
    нет, не идентичен

    ОтветитьУдалить
  77. @Александр, например, метод Proccess в этом классе: http://code.google.com/p/betteamsbattle/source/browse/trunk/ScreenShotsMaker/QueuedBetUrlProcessor/QueuedBetUrlProcessor.cs

    ОтветитьУдалить
  78. @xelibrion
    1-2 Анализировали, искали, не нашли. Предметная область изучена вдоль и поперек, за столько лет :(

    3. Вариант хороший, но в классе Task при этом вообще логики не остается, кроме INotifyPropertyChanged.
    Т.е. практически берется текущий вариант с TS, и распапаллеливается. Гораздо проще написать пересчет одной рекурсивной функцией в сервисе (как сделано сейчас), чем делать косвенную рекурсию через StartDateChanged. При этом все плюшки, типа легкого распараллеливания, остаются.

    Проблема в том, что это тот же Anemic DM, со всеми вытекающими. Если текущий вариант и будет переписываться, то в направлении еще дальшьше от DDD - пересчитывать даты можно прямо в триггере, парой рекурсивных запросов.

    @Мурадов Мурад
    >> Anemic domain это и есть Transaction Script.
    Я знаю.

    ОтветитьУдалить
  79. Я, может быть, сейчас скапитаню... но я использую Transaction Script (в том числе и для решения выше озвученных проблем) с aggregate-root-подобными сервисами. Пример. Своего рода компромисс...

    ОтветитьУдалить
  80. @Pasha, а как реализована эта задача в Transaction Script? Там ведь тоже нужно как-то вытаскивать задачи... Хочу прочувствовать, каким образом Transaction Script здесь уделывает DDD.

    ОтветитьУдалить
  81. @Idsa

    Спасибо за код, интересно было глянуть.

    ОтветитьУдалить
  82. >> Хочу прочувствовать, каким образом
    >> Transaction Script здесь уделывает DDD.

    да в том то и дело, что не уделывает, просто позволяет выбирать данные более...контролируемо и безопасно.
    Например, заранее выбрать для пересчета сразу все задачи в проекте, но в урезанном виде, с парой необходимых полей. Или заранее угадать выбираемые задачи - можно сразу выбрать все поддерево, или всех родителей до корня.

    С отсееванием циклов намного проще - при DDD придется добавлять костыли типа контекста со списком уже пересчитанных задач. Да, в TS тоже будет такой список, но он там органично вписывается.

    Заморочка не в уделывании по сложности разработки, или по количеству кода. Проблема в том, что при хорошем DDD мы получае выбор между:
    - гарантированно работает, но очень тормозит при малейшей ошибке в fetch plan-е, и с некоторой вероятностью срывается в бесконечный пересчет.
    - работает быстро, но перестает работать при малейшей ошибке в fetch плане.
    И обоих случаях fetch plan будет в сервисе, сама логика - в BE. Получается некрасивая неявная зависимость, которая никак не видна в коде, и которая критична для работоспособности приложения.

    ОтветитьУдалить
  83. Pasha,

    Прочитал все комменты, так в чем проблема? Непонятно куда запихнуть метод получения для таска связанных с ним объектов, чтоб загрузить только их и пересчитать даты (и методов в таком стиле в системе большинство)?

    Так может и ничего страшного, в том чтобы поместить такие условия запроса прямо в домен?
    Вот копипаста из фаулера:
    "Типовое решение хранилище располагается между слоем домена и слоем отображе-
    ния данных, выполняя роль коллекции объектов домена в оперативной памяти. Кли-
    ентские объекты формируют спецификации запросов и отсылают их в хранилище для
    последующего удовлетворения."
    Клиент - метод класса предметной области, спецификация - выражение линк (но можно и вообще сделать какой-нить свой объект "спецификациия" с блекджеком и шлюхами и передавать репозиторию именно его). Все, отдаем репозиторию на обработку и удовлетворение заданной спецификации - на выходе коллекция.
    Или я не прав и это уже не тру DDD style? =)

    ОтветитьУдалить
  84. Возможно уже не очень актуально, но почему бы логику не вынести в отдельные классы, в так называемые "доменные сервисы"? Тогда код приложения будет более гибок к  изменениям в бизнес логике.

    ОтветитьУдалить
  85. Если в корзину можно добавить продукты, то метод добавления продуктов будет в корзине. Зачем создавать еще один класс, в котором будет предметная область, когда уже есть классы, описывающую эту предметную область?


    > Тогда код приложения будет более гибок к изменениям в бизнес логике.
    Например? И надо сразу понимать, в чем будет гибкость?

    ОтветитьУдалить
  86. Возможно уже не очень актуально, но по поводу сдвига даты у задач (tasks) - можно не хранить конкретную дату начала определенного таска (тем более если начало одной задачи зависит от времени окончания другой) - можно просто хранить сколько времени нужно на выполнения задачи - соответственно при добавлении или изменении времени старта одной задачи не нужно будет обновлять начало зависимых задач в БД :)

    Правда придется постоянно вычислять при выводе каждой задачи это время (начиная от первой) :) например при выводе отчета "Графика всех задач"

    ОтветитьУдалить
  87. А вообще вычисления времени сдвига для зависимых задач и сохранения изменений это уровень например объекта "Проект".

    Мне лично кажется что можно инжектить repository-рии доменные объекты. Т.е. я вижу себе такой код:

    Project project = ProjectRepository.find(ProjectId);
    project.setTaskRepository(taskRepository);

    Task task = TaskRepository.find(taskId);
    task.setTime(newTime);

    project.reschedule(task);

    ----------------------------------------------

    Project.reschedule(Task task) {
    TaskRepository.reschedule(task);

    }


    ----------------------------------------------

    TaskRepository.reschedule(task) {

    // тут вызывается sql или через orm по обновлению у всех зависимых тасков времени
    // UPDATE tasks SET tasks.time = tasks.time + shift WHERE // допишите сами :)

    }


    ----------------------------------------------

    IMHO: Т.е. тут aggregation root является проект
    1. в его контексте мы соблюдаем инварианты
    2. разработчик знает где искать код (т.е. где эти инварианты реализованы) и где их нужно менять если что

    ОтветитьУдалить

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

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