Создаем IT-продукты на заказ

За 5 лет мы создали продукты с топовыми e-commerce компаниями, ритейлом, банками и другими бизнесами по всему миру.

Специализируемся на SaaS-решениях на архитектуре микросервисов, делаем аналитику IT-проекта перед стартом.

Посмотрите, что говорят о нас клиенты и как комфортно мы стартуем проекты.

Для обсуждения проекта пишите на ceo@byndyusoft.com или звоните +7 (904) 305 5263

понедельник, 3 мая 2010 г.

Domain-Driven Design: создание домена

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

Анемичная доменная модель

Если ваши доменные объекты являются контейнерами данных и всё, что в них есть, это свойства get/set, то вы используете анемичную доменную модель. Её особенностью является то, что доменный объект не имеет поведения.

Задача

Сценарии использования, к примеру, интернет-магазина:

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

Начнем с анемичной модели данных. У нас будет класс Account:

   1:  public class Account
   2:  {
   3:      public int Id { get; set; }
   4:   
   5:      public bool IsApproved { get; set; }
   6:   
   7:      public DateTime? ActivationDate { get; set; }
   8:   
   9:      public List<Order> Orders { get; set; }
  10:  }

И класс Order:

   1:  public class Order
   2:  {
   3:      public int Id { get; set; }
   4:   
   5:      public int Price { get; set; }
   6:   
   7:      public Account Account { get; set; }
   8:   
   9:      public bool IsComplete { get; set; }
  10:  }

Реализация

Каждый из сценариев работы довольно просто реализовать:

Сценарий №1. Активация пользователя

   1:  account.ActivationDate = DateTime.Now;
   2:  account.IsApproved = true;

Сценарий №2. Добавление заказа

   1:  account.Orders.Add(order);
   2:  order.Account = account;

Сценарий №3. Подсчёт общей суммы

   1:  account.Orders
   2:      .Where(order => order.IsComplete == false)
   3:      .Sum(order => order.Price);

Главный вопрос: где будет располагаться этот код?

Решение №0

Есть самое простое и неправильное решение. Мы будем писать этот код прямо в обработчиках на aspx-страницах или WinForms:

   1:  public partial class Default : Page
   2:  {
   3:      protected void Page_Load(object sender, EventArgs e)
   4:      {
   5:          // выборка объекта account
   6:   
   7:          AccountOrdersSumLabel.Text = account.Orders
   8:              .Where(order => order.IsComplete == false)
   9:              .Sum(order => order.Price);
  10:      }
  11:   
  12:      protected void AddOrderButton_Click(object sender, EventArgs e)
  13:      {
  14:          // выборка объекта account
  15:   
  16:          account.Orders.Add(order);
  17:          order.Account = account;
  18:   
  19:          // сохранение объекта account
  20:      }
  21:  }

Все будет хорошо, пока добавлять продукт можно только из этой формы, а подсчёт общей суммы происходит только по этой формуле. Проблемы начнутся, когда на другой форме потребуется такая же функциональность. Придется дублировать код. Тогда, при изменении логики работы, придется исправлять её во всех code-behind'ах.

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

Решение №1

Все-таки дублировать не будем. Мы вынесем код реализации наших сценариев в класс со звучным названием AccountHelper или AccountManager. Скорее всего этот класс будет без состояния, а потому статическим.

Получаем:

   1:  public static class AccountHelper
   2:  {
   3:      public static void Activate(Account account)
   4:      {
   5:          account.ActivationDate = DateTime.Now;
   6:          account.IsApproved = true;
   7:      }
   8:   
   9:      public static void AddOrder(Account account, Order order)
  10:      {
  11:          account.Orders.Add(order);
  12:          order.Account = account;
  13:      }
  14:   
  15:      public static int CalculateOrdersSum(Account account)
  16:      {
  17:          return account.Orders
  18:              .Where(order => order.IsComplete == false)
  19:              .Sum(order => order.Price);
  20:      }
  21:  }

Проблема классов с названием *Helper или *Manager в том, что они могут себе позволить делать всё, что угодно. Их абстрактные названия позволяют «помогать» классу Account делать абсолютно разные вещи. Такие классы со временем становятся God-object.

У таких классов множество недостатков. Например, трудно тестировать код, который использует эти классы, потому что они статические. Они делают код сильно связаным, т.к. нарушают принцип инверсии зависимостей. Очень часто из одного Helper'а вызывают другие Helper'ы. В итоге, граф зависимостей напоминает паутину из связей.

К тому же, это решение обладает всеми недостатками следующего.

Решение №2

Начнем бороться с сильной связанностью в коде. Сделаем всё правильно и создадим класс AccountService с интерфейсом IAccountService. Все объекты, которым понадобится активация или добавление заказа будут использовать интерфейс IAccountService вместо конкретной реализации. Это также поможет нам в тестировании кода.

   1:  public interface IAccountService
   2:  {
   3:      void Activate(Account account);
   4:      void AddOrder(Account account, Order order);
   5:      int CalculateOrdersSum(Account account);
   6:  }
   7:   
   8:  public class AccountService : IAccountService
   9:  {
  10:      public void Activate(Account account)
  11:      {
  12:          account.ActivationDate = DateTime.Now;
  13:          account.IsApproved = true;
  14:      }
  15:   
  16:      public void AddOrder(Account account, Order order)
  17:      {
  18:          account.Orders.Add(order);
  19:          order.Account = account;
  20:      }
  21:   
  22:      public int CalculateOrdersSum(Account account)
  23:      {
  24:          return account.Orders
  25:              .Where(order => order.IsComplete == false)
  26:              .Sum(order => order.Price);
  27:      }
  28:  }

Разобрались со связанностью и тестирование. Уже шаг вперёд. Но я вижу ещё две проблемы.

Функций типа AddOrder и CalculateOrdersSum будет довольно много. Через пол года разработки интерфейс IAccountService вырастит до 40-50 функций. «Загрязнение» интерфейса можно было бы пережить, если бы не вторая проблема.

В коде в любом месте можно в обход сервиса написать «свою активацию» пользователя. Например, взять объект Account из базы, выставить ему поле IsApproved в true и при этом забыть обновить поле ActivationDate. Тоже самое касается сценария добавления заказа. Можно вызвать функцию Add у свойства Orders где угодно и забыть выставить поле Account у добавляемого заказа. Это делает систему нестабильной. API приложения беззащитно перед пользователями системы. С таким подходом остается только надеятся, что программист найдёт нужную ему функцию в IAccountService, а не станет изобретать свой подход.

Решение №3

Поместим все эти функции в сам доменный объект Account. Обратите внимание на то, как изменились модификаторы доступа к полям объекта:

   1:  public class Account
   2:  {
   3:      private readonly List<Order> orders;
   4:   
   5:      public Account()
   6:      {
   7:          orders = new List<Order>();
   8:      }
   9:   
  10:      public int Id { get; set; }
  11:   
  12:      public bool IsApproved { get; private set; }
  13:   
  14:      public DateTime? ActivationDate { get; private set; }
  15:   
  16:      public IEnumerable<Order> Orders
  17:      {
  18:          get { return orders; }
  19:      }
  20:   
  21:      public int OrdersSum
  22:      {
  23:          get
  24:          {
  25:              return Orders
  26:                  .Where(order => order.IsComplete == false)
  27:                  .Sum(order => order.Price);
  28:          }
  29:      }
  30:   
  31:      public void Activate()
  32:      {
  33:          ActivationDate = DateTime.Now;
  34:          IsApproved = true;
  35:      }
  36:   
  37:      public void AddOrder(Order order)
  38:      {
  39:          orders.Add(order);
  40:          order.Account = this;
  41:      }
  42:  }

Теперь домен нашего приложения даёт пользователю готовое API, которое не требудет ни Helper'ов, ни сервисов. К тому же мы уберегаем пользователя от ошибок. Он уже не сможет активировать Account выставив только IsApproved. Теперь функция Activate сама заполнит нужные поля.

Заключение

Итак, если функция оперирует данными и объектами, которые находятся внутри домена, то, скорее всего, надо оставлять эту функцию внутри домена. Кроме надёжности кода, вы в добавок создадите доменный язык для вашего приложения.


Ссылки

Helper Isa Code Smell

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

  1. Interesnya statya. Ona pozvolila mne zadumatsya nad ryadom problem v moyem prilojenii

    ОтветитьУдалить
  2. @Imran Aliyev
    Это хорошо! Если будет что-то интересное, то пишите мне на почту, обсудим.

    ОтветитьУдалить
  3. отличная статья, спасибо !

    ОтветитьУдалить
  4. > (1) Функций типа AddOrder и CalculateOrdersSum будет довольно много. Через пол года разработки интерфейс IAccountService вырастит до 40-50 функций.
    > Поместим все эти функции в сам доменный объект Account.

    То есть проблему (1) решили не решать? :)

    ОтветитьУдалить
  5. всё классно, но есть вопрос: а если мы будем хранить сущности типа Account, к примеру, в базе данных, и получать их от туда посредством какой нибудь ORM, используя маппинг - то мы же не сможем установить значения полей с приватными сеттерами. Да, можно мапить на какой нибудь объект AccountToMap и потом, получив IEnumerable конвертировать его в IEnumerable - но сам понимаешь - это не решение.

    ОтветитьУдалить
  6. IEnumerable"AccountToMap" конвертировать его в IEnumerable"Account"

    ОтветитьУдалить
  7. @Артём
    Можно сделать protected set для такого поля. Например, NHibernate умеет заполнять такие коллекции.

    ОтветитьУдалить
  8. @xoposhiy
    Загрязнение интерфейса из-за добавления новых функций и добавление новых функций в доменный объект это совсем разные вещи. Первое является проблемой, второе - формирует доменный язык и проблемой не является.

    ОтветитьУдалить
  9. @Артём, @Александр
    NHibernate может заполнять и приватные сеттеры и вообще без них. Только нужно быть гуру мапинга, если писать голый xml. При использовании fluent-nh можно избежать головной боли с мапингами.

    ОтветитьУдалить
  10. Серию интересных статей по рассмотренной теме можно почитать у Jimmy Bogard в его блоге:

    http://www.lostechies.com/blogs/jimmy_bogard/archive/2010/02/03/strengthening-your-domain-a-primer.aspx

    ОтветитьУдалить
  11. > Загрязнение интерфейса из-за добавления новых функций и добавление новых функций в доменный объект это совсем разные вещи. Первое является проблемой, второе - формирует доменный язык и проблемой не является.

    Звучит примерно так же убедительно, как фраза "Бог есть!" ;)

    ОтветитьУдалить
  12. ПО-моему 'Решение №3' - это типичный ActiveRecord (который уже все предали анафеме)

    Или я неправ? Не слишком ли много класс Account знает и умеет?

    ОтветитьУдалить
  13. Как же наш старый добрый Repository? Почему ни в одном решении его реализация не приведена?

    ОтветитьУдалить
  14. @Vitaliy
    Недостаток ActiveRecord - то что, entity слишком много знает о своем хранении в базе. Здесь же оно ни про какую базу не знает.

    И причем тут репозиторий? у него другая отвественность.

    В данном примере проблема persistents и работа с базой вообще не рассматривается. Это пример про домен приложения.

    ОтветитьУдалить
  15. @xoposhiy

    Александр "неправильно" (или скорее неточно) написал. Он имел ввиду, что эти функции из какого-то непонятного god-object( в который в конце концов превратиться серивис, менеджер или помощник) вынести ближе к нужному объекту. Это не значит что все функции из AccountHelper (...Manager, ...Service) попадут именно в Account - они будут разнесены по подходящим объектам.

    ОтветитьУдалить
  16. >> Теперь функция Activate сама заполнит нужные поля.
    а кто теперь сущность сохранит?
    не надо отвечать, как в прошлом комментарии, что это application domain и тут о персистентности нет речи - ибо ответ на этот вопрос так или иначе обнажит проблемы и данного решения, которых лишён вариант с сервисами.

    ОтветитьУдалить
  17. @zerkms @Vitaliy

    Я думал это очевидно. Приведу пример кода:

    class AccountService
    {
    void ActivateAccount(int accountId)
    {
    var account = repository.Get(accountId);
    account.Activate();
    repository.Save(account);
    }
    }

    Сохранение и выборка как всегда за Repository. Доменный метод в Account. Service рулит взаимодействием.

    ОтветитьУдалить
  18. угу, очевидно.

    но всё равно перенос части логики выглядит грязным хаком. зато защищаемся, да... :-)

    ps: почему-то казалось, что в шарпе есть дружественные классы.

    ОтветитьУдалить
  19. @zerkms
    Куда по твоему надо поместить код функции account.Activate?

    ОтветитьУдалить
  20. вариант 2 был отличный. ну пусть сервис вырастает.

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

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

    ОтветитьУдалить
  21. @zerkms
    Если вариант 2 подходит для твоего проекта, то используй его. Я описал недостатки такого подхода, так что, если у тебя начнутся проблемы в коде, ты будет знать куда стремиться :)

    ОтветитьУдалить
  22. хотел бы вернуться к своему вопросу. Ну да, NHibernate умеет мапить протектед поля, ну а если я выберу другую орм? BLToolkit, насколько я помню, отказывается работать с классами, у которых нет конструктора по умолчанию, у Linq2SQL и EF свои заморочки - не будем же подстраивать наш бизнес объект под каждую ОРМ? Я привык, что всё, что приходит из базы - просто данные, не нагруженные логикой. В одном из моих проектов мы вообще для доступа к полям классов использовали интерфейсы - то есть репозиторий, например, возвращал коллекцию IAccount - где уже установлено, какие в нем должны быть поля и где должны быть геттеры и сеттеры. Не знаю, насколько это правильно, но работать с тем кодом было очень удобно.

    ОтветитьУдалить
  23. @zerkms
    Грязный хак в C# это экстеншн-методы. А тут решение вполне отличное.

    Если наша ORM позволяет сохранять все подряд и не использует кодогенерацию (NHibernate) то мы просто сохраняем/извлекаем сущность Account из базы. Если ORM генерирует какие-то свое классы, то здесь 2 более сложных решения:

    1. мы дополнительный функционал пишем в partial классах, но тогда у нас домен автоматически узнает про то как хранить сущности. И в данном случае оно равнозначно применению ActiveRecord.

    2. Мы строим еще 1 уровень абстракции, как написал Артём: у нас есть AccountData (AccountRow - называйте как хотите), мы через мэпер преобразуем его к доменному объекту и обратно. У этого решения также есть существенный недостаток - дополнительный, достаточно сложный, уровень абстракции, но при этом ваш проект остается persistent ignored.

    PS: пользуйтесь "правильными" ORM и откажитесь от кодогенерации.

    ОтветитьУдалить
  24. @Артём
    Если ты хочешь мучится со сгенерированным кодом, это твое право. Если при этом ты добиваешься хороших результатов и твой проект работает, значит ты нашел правильный путь для решения своей проблемы.

    С другой стороны, если ты не пробовал решение №3, то можешь не знать на сколько удобно с ним работать.

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

    ОтветитьУдалить
  25. @Артём
    проблема с интерфейсами состоит в том, что инвариантную логику приходся хранить в статических классах с экстеншн методами - так сделано в kigg и/или oxite. Так что решение с вторым уровнем более элегантно, но требует больших усилий.

    ОтветитьУдалить
  26. :) Да я уже пробовал все 4 варианта прямо в той последовательности, как ты написал. По поводу подстраиваться под ORM - там была как раз задача уйти от конкретной ОРМ, так как используемые технологии диктовал заказчик и было ясно, что по поводу ОРМ он мог ещё и передумать, но NHibernate не хотел использовать. И вот пришлось придумывать способ, как бы отвязаться от конкретной ОРМ. По поводу годогенерации - ну, в небольших проектах её использовать в принципе удобно, не нужно придумывать сложную архитектуру, если у тебя в программе 2 бизнес объекта, 4 aspx страницы и 3 метода в бизнес логике. А если у тебя уровень доступа к данным используется на всю катушку в 3х проектах, у каждого из которых своя логика и свой интерфейс взаимодйствия с пользователем, или вообще этого интерфейса нет, да ещё с тебя требуют несколько реализаций репозиториев, которые подключать нужно через какой нибудь DI фреймворк, выбор которого тоже ещё не закончен, то оперировать везде сущностями, нагруженными собственной логикой не хорошо. Это, конечно, только мое мнение и полемику я развивать не буду, более того, как только мне представится небольшой проект, я ещё раз попробую последовать твоему совету.

    ОтветитьУдалить
  27. @Артём
    Ты затронул интересный вопрос, который я не осветил - границы применимости.

    Можешь исходя из своего опыта описать критерии применимости всех 4х решений?

    ОтветитьУдалить
  28. @hazzik
    а у меня был специальный класс для отработки бизнес логики, который поддерживал определенный интерфейс и который я получал через DI фреймворк, а уж синглтон он или нет - это уже указывалось в настройках этого фреймворка. Такой класс и тестировать удобно было. (Кстати, огромное вам спасибо за введение в тестирование, это просто открыло новый мир в процессе создания приложений для меня ;))

    ОтветитьУдалить
  29. @Артём

    > спасибо за введение в тестирование, это просто открыло новый мир в процессе создания приложений

    Теперь у тебя есть священная обязанность рассказать об этом двум непосвященным друзьям ;)

    ОтветитьУдалить
  30. @Артём
    Про границы применимости можем обсудить в Google Wave. Если захочешь, то пиши мне на мыло.

    ОтветитьУдалить
  31. ну, по поводу решения номер 0 - мне становится не по себе, когда я вспоминаю о том, что сам нередко применял его. Это, конечно, просто недопустимо.

    решение номер 1 - применимо только с жестко зашитой логикой и только там, где класс AccountHelper доступен (привет, капитан очевидность :)). В маленьком проекте вполне сносное решение.

    Решение номер 2 - по мне вполне подходящее. Если у тебя бизнес логика в отдельной сборке и сценарии отработки бизнес логики могут меняться (то есть существует или может существовать несколько реализаций IAccountService) то почему бы и нет. Но вообще подходящим было бы создать несколько интерфейсов, каждый из которых поддерживал бы определенный набор операций над сущностями - например, IAccountViewer, IAccountActicvator (примеры, конечно, надуманные, но суть понятна) - эти все интерфейсы может реализовывать и один класс, для клиента это не важно.

    ну, и вариант 3 - по сути то тож самое, что и вариант 1, только без лишнего класса. Да, лучше нагрузить наш бизнес объект логикой, чем писать для этого отдельный класс - это я о преимуществах 3го варианта над 1, но эта логика будет доступна везде, где доступен объект - то бишь, если страница просмотра AccountView.aspx знает о классе Account, то что помешает неразумному студенту - стажеру поставить на ней кнопку активации аккаунта? Я не говорю, что этот вариант плох, везде есть свои минусы.

    ОтветитьУдалить
  32. @Артём

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

    Это называется ISP.

    PS: для каждого клиента домена предполагается свой сервисный уровень. Но, если клиент использует большинство методов какого-то существующего сервисного уровня то следует использовать этот сервисный уровень. Ну и для избежания дублирования в сервисах целесообразно использовать паттерн комманда.

    ОтветитьУдалить
  33. > Скорее всего этот класс будет без состояния, а потому статическим.

    Абсолютно несвязанные понятия. Статический класс также может иметь состояние :)

    ОтветитьУдалить
  34. @Kot
    В смысле приватная статическая переменная?

    ОтветитьУдалить
  35. Да, допустим, переменная, которая хранит число вызовов конструктора

    ОтветитьУдалить
  36. @Kot
    Да, как вариант. Но обычно, если класс, такой как AccountHelper, содержит только функции, его делают статическим.

    ОтветитьУдалить
  37. да, если вызывать методы класса напрямую, а не через какой нибудь интерфейс

    ОтветитьУдалить
  38. >> В коде в любом месте можно в обход сервиса написать «свою активацию» пользователя. Например, взять объект Account из базы, выставить ему поле IsApproved в true и при этом забыть обновить поле ActivationDate.

    а как вам такой вариант:
    public bool IsApproved { get; internal set; }
    а AccountService находится в той же сборке и имеет доступ на запись к IsApproved?

    ОтветитьУдалить
  39. @ankstoo
    Я думаю, что не получится (да и не стоит) в одной сборке держать домен и реализации сервисов.

    Хотя, если ваша архитектура позволят, то видимо можно и так :)

    ОтветитьУдалить
  40. Очень не хватает friend-классов в C#

    ОтветитьУдалить
  41. @ankstoo
    На самом деле тем лучше, что их нет. Иначе можно будет неправильные связи между сборками делать. Т.е. добавлять связи, которых по-идее не должно быть.

    А можно пример, когда friend-класс пригодится?

    ОтветитьУдалить
  42. Еще вариант - домен неявно реализует интерфейс IAccountAccess
    AccountService изменяет состояние через этот интерфейс.

    ОтветитьУдалить
  43. @ankstoo
    Вообще есть много разных способов решения. Пока самый простой это метод Activate у класса Account.

    Чем этот подход тебе не нравится? Какие видишь проблемы?

    ОтветитьУдалить
  44. >> А можно пример, когда friend-класс пригодится?

    может я просто не правильно помню как работают friend-классы.

    Но охота делать так:
    class Account
    {
    ...
    friend interface IServiceBase;
    }

    и все классы, которые реализуют IServiceBase имеют доступ к protected методам Account

    ОтветитьУдалить
  45. Разве класс-контейнер (container) не является дружественным классом для вложенных классов (nested classes) (в java еще называется внутренним классом)?

    ОтветитьУдалить
  46. @ankstoo
    Можно для сборки разрешить другой сборке видеть internal методы через [assembly:InteranslVisileTo] это убогий аналог friend

    @Kot
    Да, является, но это немного другое. Вы же не будете делать ActivationService вложенным классом в Account :))

    ОтветитьУдалить
  47. >> Чем этот подход тебе не нравится? Какие видишь проблемы?

    Смешивается структура данных и поведение в одной иерархии.

    Упрощенный пример из жизни:
    Есть объект Document. У него, среди прочих, поля Investor.Name, Investor.Pasport, Investor.INN, SignInfo.

    У документ есть метод Sign (подписать ЭЦП). При подписании проверяется валидность документа (Investor.Name, Investor.Pasport обязательны, Investor.INN обязателен и должен проходить "ключевку"). Если валидно - то формируем и устанавливаем SignInfo.

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

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

    ОтветитьУдалить
  48. @ankstoo
    Это логика явно выходит за границы домена и должна быть в сервисе. Вообще валидация не относится к языку домена и должна быть от него отделена.

    ОтветитьУдалить
  49. >> Это логика явно выходит за границы домена и должна быть в сервисе.

    а как IDocumentSignService получит доступ на запись к свойству SignInfo?

    ОтветитьУдалить
  50. @ankstoo
    Надо смотреть конкретный код, но в общем случае, после подготовки и проверки данных сервисом, можно вызвать

    document.Sing(preparedParams);

    ОтветитьУдалить
  51. Было бы здорово увидеть пример связки DDD с NHibernate или EF.

    ОтветитьУдалить
  52. Привет всем!

    Александр, спасибо за полезную статью.
    Вот мое небольшое дополнение:

    http://www.handcode.ru/2010/05/domain-driven-design.html

    ОтветитьУдалить
  53. @Dmitry Sukhovilin
    У меня будут еще статьи по DDD. Думаю после них могу сделать обобщение и привести пример с NHibernate.

    ОтветитьУдалить
  54. >> Было бы здорово увидеть пример связки DDD с NHibernate
    в книге Джимми Нильсонна "DDD" есть отдельная глава на эту тему

    ОтветитьУдалить
  55. @ankstoo
    Только когда он ее писал еще не было FluentNHibernate :)

    ОтветитьУдалить
  56. Прочту Нильсона, спасибо за совет.

    DDD Bible от Eric Evans впечалила, а вот

    .NET Domain-Driven Design with C#: Problem - Design - Solution (Programmer to Programmer)

    http://www.amazon.com/gp/product/0470147563/ref=cm_sw_r_fa_dp

    Очень разочаровала.

    ОтветитьУдалить
  57. Это хорошо работает с NHibernate. С EF насождение логики в сущность куда более проблематична =\

    ОтветитьУдалить
  58. @Dmitry Sukhovilin
    Вообще серии типа "Problem - Design - Solution" полезными редко бывают.

    ОтветитьУдалить
  59. @Алексей
    Кодогенерация еще никого до добра не доводила ;)

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

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

    Еще проблема в "контексте": что, если логика добавления заказа зависит от типа аккаунта, или места его использования?

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

    Насколько я понял, решение №2 относится к частично-анемичной модели. Эдакий компромисс: в ней есть доменные методы, типа activate, которые не зависят от внешних классов, хотя и содержат логику. Остальные же вынесены в Сервисный слой.

    ОтветитьУдалить
  61. @Constantine
    Спасибо за дополнения, очень ценно.

    Интересно будет посмотреть на примеры:

    > что, если логика добавления заказа зависит от типа аккаунта, или места его использования?

    и

    > В дополнение столкнулся с проблемой цепи зависимостей

    Если будет много кода, кидай на почту.

    Спасибо!

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

    блин, блоггер глюкнул и съел каммент :(

    >контекст
    есть Account и GoldAccount, оба реализуют AccountService. У золотого при суммировании заказа есть скидка

    >цепь зависимостей
    сталкивался, когда когда разбирался с Hibernate, посему кода не имею. Покажу на примере из статьи

    Форма просмотра заказа, взаимодействует с доменом Заказ. Мы при этом хотим видеть имя аккаунта и количество заказавших похожий заказ (небольшое количество информации из доменов, находящихся выше уровнем). В итоге это может вылиться в то, что домен Аккаунт станет равнозначным домену Заказ, и непонятно кто кого должен содержать.

    ОтветитьУдалить
  63. @Constantine
    > есть Account и GoldAccount, оба реализуют AccountService

    Как доменный объект может реализовывать сервис?

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

    Мне кажется ты путаешь Order и Product. У Order будет набор Products и общая сумма заказа. Т.е. связь Account -> Order -> Product и будет работать обратная Product -> Order -> Account.

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

    не совсем корректно написал.
    Есть два аккаунта, простой и золотой. Внутреннее представление у них идентично, API тоже одинаков, но внутренняя логика отличается (% скидки), также могут отличаться связи с другими классами (автоматическая оформление доставки у золотого).

    >будет работать обратная
    хм, интересно, а каким образом будет тогда построена иерархия классов?

    у детей будет ссылка на родителей?

    ОтветитьУдалить
  65. @Constantine
    Можешь код написать и задать вопрос по коду? Так не понятно в чем проблема.

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

    Для скидок целесообразно применить паттерн double dispatch:
    Свойство OrdersSum будет методом и будет принимать в качестве аргумента реализацию доменного сервиса IOrdersSumCalculator

    public double OrdersSum (IOrdersSumCalculator calc) { return calc.Calculate(this); }

    А оформление доставки это точно функция сервиса, а не доменного объекта

    ОтветитьУдалить
  67. @hazzik
    Остается вопрос, кто будет выбирать какой IOrdersSumCalculator подставить в эту функцию?

    ОтветитьУдалить
  68. Вот мне только кажется, или Решение №3 называется "инкапсуляция" в терминологии классического ООП? )

    ОтветитьУдалить
  69. @hazzik
    Ага, просто интересно было, вдруг у тебя уже есть готовое решение.

    ОтветитьУдалить
  70. Откуда у меня будет готовое решение для сферического примера в вакууме? Как будет удобно сделать с точки зрения TDD и использования так и будет сделано, если вообще нужно будет.

    ОтветитьУдалить
  71. скажите protected set в EF работает?...скажем если я хочу CodeFirst...

    ОтветитьУдалить
  72. @Ринат Муллаянов

    На счет EF не знаю, а в NH работает.

    ОтветитьУдалить
  73. А если чуть усложнить ваш пример, допустим введем новое бизнеc правило: к счету нельзя добавлять заказ, если у него уже есть заказ в статусе IsComplete = true. Куда мы добавим эту проверку? Очевидно, в метод AddOrder класса Account, т.к. именно он отвечает у нас за добавление заказа к счету.
    Но в таком случае если у нас после того как проверка дала положительный результат, но до того как этот заказ был добавлен - другой пользователь добавит заказ в статусе IsComplete=true, то т.к. проверка уже прошла, заказ первого пользователя будет спокойно добавлен, а правило регламента нарушено. Очевидно что нужно добавление и проверку делать в транзакции СУБД, но транзакция СУБД - это не слой домена.
    Как вы выйдете из подобной ситуации?

    ОтветитьУдалить
  74. @SomewhereSomehow

    Тут зависит от нагрузки приложения.

    Самый простой вариант поставить Lock в этом месте.

    Другой вариант при открытии транзакции указать Isolation Level, который не допускает такой ситуации.

    А вы как уже пробовали решать проблему?

    ОтветитьУдалить
  75. Когда действия выполняются в транзакции - это понятно. Но вопрос, как раз в том и состоит, где открывать эту транзакцию, как при этом не смешать слой доступа к данным с и слой модели предметной области. Если мы доупскаем прямое манипулирование транзакциями в модели - вопроса не возникает. Но это нарушает DDD-style, разве нет?=)

    lock имеется ввиду средво языка? тогда не поможет, мы боремся не с многопоточностью, а с тем что у системы может быть много пользователей. например, это корпоративная CRM или веб-сайт, и возникает ситуация, когда два объекта представляют одну и ту же сущность, но при этом не подозревают друг о друге. КОгда же работа идет на уровне данных, при одновременности операций включается в работу механизм блокировок, атк что данные в транзакции не могут получиться несогласованными.
    А вопрос, как предполагается решать эту проблему в случае подхода DDD и размещения логики не в базе данных или слое доступа к данным (тот самы нелюбимый ООПшниками типа Эванса и Фаулера "процедурный" transaction script), а в "элегантной" модели предметной области.

    Я знакомлюсь с концепцией DDD, разбираюсь что и как. Раньше я использовал то, что принято называть anemic domain model и transaction script, а в силу их особенностей, таких вопросов просто не возникало. Все естественным образом укладывалось в модель транзакций. Тут устроено иначе и я хочу выяснить как именно у людей с этим успешно работающих. То бишь, в данном случае вас =)

    ОтветитьУдалить
  76. @SomewhereSomehow

    Если говорить про MVC, то управление транзакцией осуществляется на уровне контроллеров объектом UnitOfWork, у меня в блоге про это было много примеров.

    ОтветитьУдалить
  77. Допустим говорим про MVC.
    Но UnitOfWork вроде используется только для корректности операций создания/изменения/удаления? Или предлагается туда каким-то образом поместить получение данных - ведь нам надо их получить из БД, чтобы проверить, нет ли завершенных заказов?
    Вот например код:
    public void AddOrder(Order order)
    {
    // если нет завершенных заказов, добавляем
    if (!this.hasCompletedOrders())
    {
    orders.Add(order);
    order.Account = this;
    }
    }
    где hasCompletedOrders() - делает запрос к БД и проверяет, есть ли у счета завершенные заказы.

    как это будет выглядеть в UoW?

    ОтветитьУдалить
  78. @SomewhereSomehow

    Вот два поста
    http://blog.byndyu.ru/2010/05/1.html
    и
    http://blog.byndyu.ru/2010/07/2-unit-of-work.html

    Там всё подробно описано.

    ОтветитьУдалить
  79. Я прочитал приведенное по ссылке.
    Правильно ли я понимаю, что по сути, вы предлагаете взамен транзакций ввести еще одну абстракцию, называемую UnitOfWork, внутри которой по-сути будет открываться та же транзакция к БД? Я не нашел что конкретно скрывается за: IUnitOfWork unitOfWork = unitOfWorkFactory.Create().

    ОтветитьУдалить
  80. @SomewhereSomehow

    За ней скрывается, например, Session от NHibernate или просто TransactionScope.

    ОтветитьУдалить
  81. NHibernate я не использую, но идею примерно понял.

    Только почему "управление транзакцией осуществляется на уровне контроллеров объектом UnitOfWork"? Вот пример с заказом и счетом, ведь по-сути, обязанность проверить правило, что у счета нет завершенных заказов - это обязанность доменной области. И логично поместить эту проверку в метод AddOrder самого счета, обложив ее транзакцией посредством UoW. Если возложить эту обязанность на контроллер получается он должен знать как устроен метод AddOrder. Если на контроллер возложить саму проверку - это будет означать, что уровень операционной логики занимается вопросами предметной логики.

    ОтветитьУдалить
  82. @SomewhereSomehow

    > И логично поместить эту проверку в метод AddOrder самого счета, обложив ее транзакцией посредством UoW

    Проверка и будет в AddOrder, а UoW будет создан в контроллере. Они друг про друга не будут знать.

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

    ОтветитьУдалить
  84. @SomewhereSomehow

    Посмотрите примеры http://blog.byndyu.ru/2010/07/2-unit-of-work_10.html

    UoW создается в котроллере, в доменном объекте нет ссылки на UoW. Например, метод client.Lock() меняет состояние объекта. Изменение эти данных UoW отслеживает автоматически. Это умеет делать, например, NHibernate.

    ОтветитьУдалить
  85. хм...а коменты-то зачем тереть...

    ОтветитьУдалить
  86. @SomewhereSomehow

    Ваш комментарий пришел мне на почту, но почему-то не отобразился на сайте. Ниже копирую его из письма:

    Спасибо за ответы, но... Я понимаю конечно, что трудно нести DDD в массы, но раз уж вы взялись, я попросил бы не опираться на конкретные реализации в ответах (я уже кстати упоминал, что не использую NHibernate), по этому давайте в рамках общих шаблонов и подходов. Ответ в духе "а NHibernate это умеет" - не считается =)

    Примеры я посмотрел еще до того как задать вопрос (и вообще просмотрел в вашем блоге все связанные темы). А вы мой вопрос прочитали внимательно? Я тогда специально повторю еще раз, и выделю нужную часть:
    "Если же сам вызов метода в контроллере будет обложен UoW, то подразумевается, что вызывающая сторона должна представлять, что происходит внутри метода чтобы иметь резон обложить его вызов в UoW."
    А вы отослали меня к коду, по которому я же и задал вопрос. Есть подозрение, что "вы не отвечаете на мой ответ" =))

    ОтветитьУдалить
  87. @SomewhereSomehow

    Дело в том, что вы и сами можете реализовать UoW. Прочитайте про него в книжке Фаулера Архитектура корпоративных приложений, вот ссылка на схему с небольшими комментариями http://martinfowler.com/eaaCatalog/unitOfWork.html

    UoW создается на уровне контроллера, сущности берутся из UoW и кладутся обратно в UoW. Плюс UoW отслеживает изменения в сущностях. Да, это уже реализовано во многих ORM, но вы можете глядя на теорию, написать свою реализацию.

    ОтветитьУдалить
  88. Добрый день. Есть небольшрй вопрос недопонимания. Получаетя вы проектируете(пишите) модель предметной облати (DDD). а затем привязываете к ней маппинг NHibernate?

    ОтветитьУдалить
  89. Да, все верно. Причем маппинг нужен, только если БД нужна сразу. Для начала разработки можно использовать БД в памяти или текстовые файлы. Маппинги уже следующий шаг.

    ОтветитьУдалить
  90. Здравствуйте. У нас так: Есть сборка DataAccess с конфигурацией, маппингами и т.п. (используем NHibernate); Есть сборка DomainModel. DA имеет ссылку на DM. Вопрос следующий: что делать с логикой, которая нуждается в каких либо данных из БД? Я же не могу в функциях доменного объекта использовать ORM. Приходится создавать 3ую сборку Services, но это не гуд (как раз то, что описывалось выше в статье). Как быть?

    ОтветитьУдалить
  91. То, что создается отдельный слой для манипулирования объектам - это нормально, иначе не получится. Просто это могут быть сервисы, в каждом по десятку методов, а могут быть команды (шаблон Command), где каждая команда будет отвечать за свою часть логики.

    ОтветитьУдалить
  92. Александр, можно и правильно ли использовать доменные объекты в подходе code first для EF? И если изначально это анемичные доменные объекты в дальнейшем отрефакторить их в соответствии с "Решение 3" (добавить API и изменить модификаторы доступа у сеттеров)?
    И еще вопрос:
    "Функций типа AddOrder и CalculateOrdersSum будет довольно много. Через пол года разработки интерфейс IAccountService вырастит до 40-50 функций." Могут ли вообще пригодиться такие интерфейсы IAccountService и где?

    ОтветитьУдалить
  93. Денис, можно, для этого нет никаких ограничений. У нас есть проекты на EF, мы там во всю используем.

    "Могут ли вообще пригодиться такие интерфейсы IAccountService и где?"

    Да, могут. Вот наглядный пример интерфейса https://github.com/AlexanderByndyu/ByndyuSoft.Infrastructure/blob/master/samples/aspnetmvc/src/Web.Application/Account/Services/IAuthenticationService.cs и его реализации https://github.com/AlexanderByndyu/ByndyuSoft.Infrastructure/blob/master/samples/aspnetmvc/src/Web.Application/Account/Services/Impl/FormsAuthenticationService.cs

    ОтветитьУдалить
  94. Статье, конечно, уже не один год... Но надеюсь на ответ. Так, хорошо, с account.AddOrder, account.Activate и т.п. всё в общем-то понятно. У Order, допустим, тоже есть некоторые order.AddProduct, order.Approve и т.д. А ещё у нас есть продукты, у которых есть целый ряд различных свойств (article, title, shortTitle, url, description, features[]). Менеджер/оператор имеет возможность их все редактировать, да и как создавать такие сущности не понятно, если у них не должно быть публичных сеттеров. productUpdate(data) или что это должно быть в домене?

    Допустим в контексте формирования заказа для нас это всё не имеет значения. Тогда делать две сущность ProductForOrderContext и ProductForEditingContext? В таком случае количество сущностей может значительно разростататься, да и во втором случае "сущность" опять таки будет анемичной, по сути даже просто DTO. В общем не понятно...

    ОтветитьУдалить
  95. "второе - формирует доменный язык и проблемой не является."

    Очень даже является проблемой, даже несколькими:

    § Смешивается в общую кучу логика например -- для гостя, зарегистрированого пользователя, привелигированого пользователя, администратора, ...

    § Класс со временем становятся God-object-ом.

    § Нарушается принцип SOLID.
    А именно S -- на каждый класс должна
    быть возложена одна-единственная обязанность.

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

Создаем IT-продукты на заказ

За 5 лет мы создали продукты с топовыми e-commerce компаниями, ритейлом, банками и другими бизнесами по всему миру.

Специализируемся на SaaS-решениях на архитектуре микросервисов, делаем аналитику IT-проекта перед стартом.

Посмотрите, что говорят о нас клиенты и как комфортно мы стартуем проекты.

Для обсуждения проекта пишите на ceo@byndyusoft.com или звоните +7 (904) 305 5263