Feel Good.

04 мая 2010

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

На днях прочитал полезную статью от Александра Бындю Domain-Driven Design: создание домена, подумав, я решил поделиться своим мнением и рассказать о том, как обычно разворачиваю домены в своих проектах, в частности рассказать о том, как для этого можно задействовать Entity Framework.

Во-первых, если Вы имеете дело с доменными объектами, то старайтесь всегда выделять базовые (общие) свойства.

// Общий для всех доменных объектов интерфейс.

// Очень удобно работать с доменными объектами

// зная, что все они обладают общим свойством.

// Например, у из них каждого есть идентификатор Id.

interface IEntity

{

    int Id { get; set; }

 

    // Здесь можно продолжить описывать ОБЩИЕ для всех

    // свойства нашего доменного объекта:

    //

    // bool Enabled { get; set; }

    // DateTime CreatedAt { get; set; }

}


Во-вторых, очень часто мне приходиться описывать доменный объект при помощи интерфейса, так это удобно и правильно, советую и Вам применять эту тактику:

// Интерфейс доменного объекта "Человек".

// Во всех сервис-методах, в методах доступа к данным(репозитории),

// в модели представления, используем именно IPerson.

interface IPerson : IEntity

{

    string Name { get; set; }

 

    int Age { get; set; }

 

    // Логика, над доменным объектом.

    void SayHello();

}


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

// Конкретная реализация интерфейса IPerson:

partial class Person : IPerson

{

    public int Id { get; set; }

    public string Name { get; set; }

    public int Age { get; set; }

 

    public void SayHello()

    {

        Console.WriteLine("{0}: Hello!", Name);

    }

 

    // Если надо, то после рефакторинга здесь

    // могут появиться protected/private методы.

}


или при использовании любого ORM, например Entity Framework, при таком подходе Вы не зависите от конкретного ORM (зависимость только от интерфейса IPerson):

// Если мы имеем дело с Entity Framework

// (или любой другой ORM), в котором кодогенерацией

// уже был сгенерирован класс Person с нужными полями...

partial class Person : EntityObject

{

    // Здесь нам EF нагенерировал кучу всего...

    // в том числе реализовал Id, Name, Age.

 

    // Этот код руками трогать нельзя, иначе

    // повторная кодогенерация перетрет все труды!

}

 

 

// ... то нам просто остается реализовать

// не реализованное через partial класс:

partial class Person : IPerson

{

    public void SayHello()

    {

        Console.WriteLine("{0}: Hello!", Name);

    }

}



Ссылки:
  1. Domain-driven design (DDD)

44 комментария:

  1. Как быть с ассоциированными колекциями при таком подходе?

    Например у Person есть множество Order.

    ОтветитьУдалить
  2. @hazzik

    Хороший вопрос!

    interface IOrder {}


    interface IPerson
    {
    IOrder[] Orders{get;set;}
    }

    partial class Person : IPerson
    {
    public IOrder[] Orders
    {
    get
    {
    [здесь маппинг на внутреннюю коллекцию]
    }
    set
    {
    [здесь маппинг на внутреннюю коллекцию]
    }
    }
    }

    ОтветитьУдалить
  3. Можно подробнее вот про эту часть: [здесь маппинг на внутреннюю коллекцию], я хотел узнать именно это?

    Теперь другой аспект - в один прекрасный день новый заказчкик вашего продукта говорит, что он хочет использовать другую ORM, и это его ключевое условие, а упускать заказчика не хочется.

    Тебе необходимо будет все свои интерфейсы доменных объектов IProduct, IOrder и т.д. переимплементировать под новую ORM, а в реальном приложении этих интерфейса не 2, и не 10, а много больше.

    Вознникнет огромное дублирование кода, и как ты собираешься поддерживать эти интерфейсы?

    ЗЫ: для убедительности - проект просто огромный, и заказчик солидный с большими деньгами.

    ЗЫЫ: премодерация + капча это перебор.

    ОтветитьУдалить
  4. @hazzik

    Да, конечно. Например, если использовать EF:
    partial class Person : IPerson
    {
    public IOrder[] Orders
    {
    get
    {
    this.Order.Load();
    return this.Order.ToArray();
    }
    }
    }

    При смене ORM, сильнее всего пострадает репозитарий, так как именно на него возлагается функционал добавления, сохранения, удаления объектов, ну и не обойдется без модификации доменных объектов.

    С другой стороны, не используя интерфейсы, при смене ORM пришлось бы вмешиваться в логику сервис-методов. Это ведь не совсем красиво, когда ты описываешь интерфейсную часть репозитария или сервиса и при этом привязываешься к конкретному ORM, используя его как домен. Например, новый ORM все поля оборачивал бы string в ILazy, а старые сервис-методы были заточены под обычный string. А с маппингом проблема вроде решается.

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

    ОтветитьУдалить
  5. Я правильно понял, что основная мысль - это создание интерфейсов для доменных объектов?

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

    ОтветитьУдалить
  6. я бы хотел вклиниться в дискуссию со своими заметками :)
    1.
    interface IEntity
    {
    int Id { get; set; }
    .....

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

    2.
    interface IPerson : IEntity
    {
    string Name { get; set; }
    int Age { get; set; }
    // Логика, над доменным объектом.
    void SayHello();
    }

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

    3.
    partial class Person : EntityObject {...}

    Жесть, реально, при смене ОРМ твои доменные объекты перестанут быть наследниками EntityObject - а это, согласись, весьма серьёзные изменения для него. А если таких объектов у тебя не 2, не 20, а 200? Опять же, если в требованиях четко указано, что менять ОРМ не планируется и именно EF требует заказчик, то, почему бы и нет :)

    4.

    // Во всех сервис-методах, в методах доступа к данным(репозитории),
    // в модели представления, используем именно IPerson.
    interface IPerson : IEntity {...}

    Можно посмотреть на код, который бы добавлял человека в базу? Где и как экземпляр этого класса будет инстанцироваться? то есть например, пользователь нажимает кнопку создать, где то в логике создается экземпляр IPerson (только как создается? о классе Person логика ведь не должна знать? у неё свой наследник интерфейса IPerson?), потом заполняются поля этого экземпляра и он едет в репозиторий, который принимает IPerson. Ну теперь в репозитории: у нас нечто, что реализует IPerson, но неизвестно, можно ли это нечно мапить в EF - а, значит, мы в репозитории тоже создаём экземпляр Person, заполняем его поля данными из пришедшего в параметрах IPerson и только тогда добавляем? В общем, хотелось бы на код поглядеть :)

    ОтветитьУдалить
  7. >ну и не обойдется без модификации доменных объектов.
    Важное условие моей задачи: НЕ полностью перейти на другую ОРМ, а сделать дополнительно ее поддержку. Не будешь же ты своим существующим клиентам впаривать новую ORM.

    ОтветитьУдалить
  8. @Александр Бындю

    Да, основная мысль в том, чтобы при описании интерфейсов сервиса IPersonService или репозитория IPersonRepository, использовать интерфейс IPerson, в противном случае мы будем иметь зависимость например от ORM.

    С консолью Вы правы, но это всего лишь пример :)

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

    1. IEntity.Id удобно использовать в общих фильтрах, например GetById[TEntity](int id).

    Плюс IEntity может выступает в роли whrere условия у generic типов.

    Тип у Id в IEntity можно сделать любым, например struct Identificator {} и дальше управлять им.

    2. Спорить не буду, дело вкуса :)

    3. При смене ORM всегда будут сложности.

    4. Есть код:
    _dc - наш EF.
    IAccount - домен.
    Account : IAccount - сущность в EF.
    Account.New - мой метод, строит экземпляр Account

    При добавлении я не передаю сам объект:

    public void AddNew(string login, string email, string passwordHash)
    {
    _dc.AddToAccount(Account.New(login, email, passwordHash));
    }

    А вот при поиске(фильтре):

    public IAccount FindBySessionKey(Guid sessionKey)
    {
    return _dc.Account.Enabled().Where(a => a.SessionKey == sessionKey).FirstOrDefault();
    }

    ОтветитьУдалить
  10. @hazzik

    Если поддержка старой и новой, тогда как-то так:

    class Person : IPerson
    {
    public string Name
    {
    if(isUseNewORM)
    {return [новая ORM]}
    else
    {return [старая ORM]}
    }
    }

    Это псевдокод, ясно что от if надо избавиться по-умному. Например используя IoC: Resolve[IORMMapper]("ef");

    ОтветитьУдалить
  11. а как это будет работать одновременно с двумя репозиториями?
    Например, один EF, а другой просто отправляет объекты на web-сервис.
    Это как то решается с помощью partial-классов?

    ОтветитьУдалить
  12. @ankstoo

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

    ОтветитьУдалить
  13. В основном, partial класс нужен только когда, когда мы хотим расширить сгенерированный машиной код. Решать другие задачи partial не умеет :)

    ОтветитьУдалить
  14. @Илья
    1. Все бизнес объекты ты от IEntity не отнаследуешь, потому что:
    а) не у всех объектов идентификаторы одинаковые (Ну, это можно решить обобщениями)
    б) не у всех бизнес объектов есть идентификаторы, например, отношения многие-ко-многим (кстати, такие объекты отказываются мапиться в Linq2SQL)
    Ну, суть этого интерфейса я понял, это ладно.

    3. Можно свести набор этих сложностей к минимуму :).

    4. ну, с аккаунтом всё понятно, а если у тебя заказ и список товаров, и всё это нужно положить в базу в рамках одной транзакции, как бы ты это сделал?

    @hazzik
    "Не будешь же ты своим существующим клиентам впаривать новую ORM"
    я ни в жись не буду, но бывают случаи, когда по поводу ОРМ заказчик ещё не определился, или какой нибудь супер крутой программист из конторы заказчика поглядит на код, что уже 2 месяца в разработке и скажет, что "L2SQL фигня, BLToolkit работает намного быстрее, а вот эти данные вообще надо пролучать из сервиса, а сервис будет запущен через месяц" ну и всё такое прочее, а в техзадании стоит галка "расширяемость и модульность системы" и сроки давят - вот тогда начинаешь думать, как бы спроектировать систему так, чтобы изменение одной её части не затрагивало никакие другие

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

    Фразой: "Не будешь же ты своим существующим клиентам впаривать новую ORM" - под этим я имел ввиду, что есть уже заказчики с внедренным продуктом, которым нужна только поддержка + обновление + исправление багов. В большинстве случаев твой продукт уже проинтегрирован с другими продуктами, часто на уровне домена и т.п.

    @Илья
    Ваше решение не заработает.

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

    4. Эту проблему берет на себя EF. Достаточно "накопить" в нем изменения (тот же репозитарий), а потом вызывать метод Save у репозитария. На каждую транзакцию придется строить новый экземпляр EF.

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

    А как бы Вы сделали поддержку 2-х и более ORM одновременно, не привязываясь конкретно к каждой?

    ОтветитьУдалить
  18. 4. тут можно заставить создавать объекты сам репозиторий.
    IAccount acc = Repository.NewAccount();
    ShowUI(acc);
    Repository.AddAccount(acc);
    Repository.Save();

    ОтветитьУдалить
  19. @Артём сущность и доменный объект это 2 разных понятия.
    Сущность от ValuеОbject отличает механизмом идентификации. Идентификатором сущности является ключ. Идентификатором ValueObject совокупность его полей (зачастую всех).

    ОтветитьУдалить
  20. @hazzik понял тебя
    @Илья - так в том то и дело, где ты будешь вызывать начало/конец транзакции? не в коде страницы жеж :)
    @ankstoo конечно можно, мало того - иногда так и делаю, только вот проблема - экземпляр доменного объекта почему то создает репозиторий - это вообще никак не его обязанность :)

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

    Конечно, все в сервис-классе(Service-layer), который инжектирует в себя репозитарий(Repository).

    ОтветитьУдалить
  22. @Илья, вы не понимаете сути домена. Домен это ядро приложения. Если ваш домен зависит от ORM - все ваше приложение зависит от ORM.

    Решение следующее: Repository оперирует именно доменными объектами, и ВНУТРИ себя ЧЕРЕЗ мэпер делает преобразование DataRow <-> Entity.

    NHibernate в общем случае позволяет избавиться от мапинга, потому что ОН уже берет на себя это преобразование.

    Если вы читали PoEAA, то вы не могли не заметить, что почти все патерны из нее реализованы внутри NH, но естественно называются по-другому.

    ОтветитьУдалить
  23. @Илья
    то есть у тебя сервис-класс управляет транзакциями? Это не гуд

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

    Почему? Достаточно приемлемо. Сервис открывает транцакцию, точнее UnitOfWork, а репозитории работают в текущем UnitOfWork, в конце делаем Commit для UnitOfWork.

    ОтветитьУдалить
  25. небольшой пример, если можно

    ОтветитьУдалить
  26. На каждый экземпляр сервиса создаем новый репозиторий, который в себе создает UnitOfWork, либо инжектирует его:
    IRepository r = new Repository(/*инжектирует*/)

    В сервис метод инжектируем созданный репозиторий, который потом используем в сервис методе.
    void MakeBigWork()
    {
    // используем наши репозиторий..
    r.Save(); //<- Save сохранит все измеения в БД
    }

    Можно использовать TransactionScope, все зависит от конкретной задачи

    Это если по-простому.
    Либо, можно посмотреть исходники Kigg.codeplex.com, там подобное реализовано очень хорошо, через абстрактный интерфейс IDataBase.

    ОтветитьУдалить
  27. @hazzik

    Здесь Вы ошибаетесь, что такое домен и как с ним работать я знаю.

    >>Если ваш домен зависит от ORM - все ваше приложение зависит от ORM.
    Пересмотрите топики, я это сказал еще в самом начале. Собственно здесь мы и пытаемся разорвать эту связь.

    С NHibernate не работал, но представляю, что он работает как стандартный ORM.

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

    Если ORM может работать с доменом как с POCO, т.е. не требовать от домена наследоваться от спец класса, реализовывать спец интерфейсы или иметь спец атрибуты, то такая проблема даже не появляется.

    NHibernate умее, EF в 4 версии вроде тоже.

    ОтветитьУдалить
  29. Да, спасибо, абсолютно точно подмечено. В случае с EF нам "мешает" базовый тип System.Data.Objects.DataClasses.EntityObject.
    Кстати, глянул в NHibernate, там такой проблемы нет. Надо будет глянуть применение T4 в EF4.

    ОтветитьУдалить
  30. Что будет с инвариантной логикой, которая находиться в сущностях? Куда она денется при смене ОРМ?

    ОтветитьУдалить
  31. @ankstoo
    Доменная сущность и объект отраженный из базы это совершенно разные вещи. И отражение называется Object Relation Mapping. А не Domain Entity-Relation Mapping

    ОтветитьУдалить
  32. @hazzik

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

    ОтветитьУдалить
  33. @Илья
    Я хочу получить конкретный ответ:)

    ОтветитьУдалить
  34. @hazzik

    В идеале :)
    Логика никуда не должна деваться (если только не захотите вынести ее в helper-ы)

    Логика никак не должна меняться.

    ОтветитьУдалить
  35. >если только не захотите вынести ее в helper-ы
    Чем это будет отличаться от примера 1, из статьи Александра Бындю?


    Ну смотрите, псевдо-реальный пример:

    У меня есть IAccount:

    public interface IAccount {
    bool Activated {get;}
    void Activate(); /*other methods*/
    }

    И есть конкретная реализация под EF:

    public class Account : EntityObject {
    public bool Activated {get;set;} // ef realization
    }

    partial class Account : IAccount {
    public void Activate() {
    this.Activated = true;
    // other activation logic
    }
    }

    И теперь мне нужно *ДОБАВИТЬ* поддержку NH, как того требует мой щедрый новый заказчик. Добавить таким образом, чтоб существующие пользователи моего продукта не испытывали никаких неудобств - их полностью устраивает EF и они не собираются его менять.

    Мои действия?

    ОтветитьУдалить
  36. 1. В helper-ы можно вынести повторный код.
    partial class Account : IAccount {
    public void Some() {
    // вызвать helper, как вариант
    }
    }
    Те же самые kigg, например в классе Tag, свойство Stories юзает EntityHelper.

    2. Я не силен в NH, не могу сказать, может там есть нечто специфическое. Но подобное реализовано в kigg, где организовано переключение между LinkToSql и EF. Я особо не копался в исходниках, поэтому детально не расскажу. Например, можно подсмотреть реализацию свойства int StoryCount, в классе Tag.

    PS: Да, щедрость заказчика определяет многое :)

    ОтветитьУдалить
  37. Это вопрос из серии, "Как сделать поддержку нескольких систем логирования или нескольких IoC контейнеров". Данная проблема решается в лоб, с помощью введения своих внутренних интерфейсов ILog, IDependencyResolver.

    ОтветитьУдалить
  38. > Я не силен в NH, не могу сказать, может там есть нечто специфическое.
    Под NH имеловсь ввиду сферический ORM в ваккууме, будь то L2S, NH, LLBLGenPro.

    Чем тогда ваше решение с хелперами будет лучше чем пример 1 из сататьи Александра, от которого он так старательно избавляется?

    ОтветитьУдалить
  39. Если например код из CalculateOrdersSum будет повторяться в других сущностях, то его проще вынести в helper.
    В моих проектах всегда есть helper-расширения для IQueryble.
    Например:
    return account.Orders.Enabled().Where(order => order.IsComplete == false);

    Где под Enabled() позволяет избежать дублирование Where(entity => entity.Enabled);

    ОтветитьУдалить
  40. @hazzik
    >> Доменная сущность и объект отраженный из базы это совершенно разные вещи.

    Т.е. сначала мапим базу на объекты данных. Потом мапим объекты данных на доменную модель? Так?

    ОтветитьУдалить
  41. @ankstoo
    Так.

    Просто попытайтесь выделить ответственности такой на половину сгенерированной entity. И вы все сами поймете. Она несет в себе лишнюю ответсвенность - хранит информацию о том как она сохраняется в базе. Таким образом, видно что большинство ORM, требующие наследоваться от своего базового класса - нарушают SRP.

    И еще раз повторю, что все что здесь мной сказано относиться к моему примеру с щедрым новым заказчиком и требованием сохранить старую ОРМ для старых заказчиков.

    PS: Если использовать интерфейсы для того, что нагенерировала нам ОРМ, то можно возложить обязанность преобразования на сам доменный объект.

    пример:

    public interface IAccountRow { // наш интерфейс, чтоб отвязаться от ORM
    int Id {get;set;}
    string Name {get;set;}
    }

    public class AccountRow : EntityObject, IAccountRow { // то что нам нагенерировала EF
    public int Id {get;set;}
    public string Name {get;set;}
    }

    public class Account : IEntity { // доменный объект
    private IAccountRow row;
    public int Id {get{ return row.Id;} set{ row.Id = value;}}
    public string Name {get { return row.Name;}set{ row.Name = value;}}
    }

    Но с этим подходом когда-нибудь также возникнут трудности.

    PS: следуйте SOLID и у вас все получиться.
    PSS: помните, что SOLID это только принципы, а не законы и правила. Они требуют подхода к делу с головой.

    ОтветитьУдалить
  42. @hazzik
    >> И вы все сами поймете.
    я понимаю :) Если домен наследуется от EntityObject или чего-то подобного - это плохо, т.к. он зависит от persistence. Намного лучше, чтобы домен был POCO.
    Но в случае, например, NHibernate, вводить дополнительный слой ORM-объектов бессмысленно. NH отлично работает с POCO доменом. Об этом я и писал.

    >> PS:... Но с этим подходом когда-нибудь также возникнут трудности.
    Тут трудности очевидны. Мы не можем использовать доменный объект без persistence (Например в unit-тестах). Или надо писать MockAccountRow.

    ОтветитьУдалить
  43. @ankstoo
    к вам притензий нет и я поддерживаю точку зрения, что если ОРМ не предъявляет к сохраняемым объектам специфических требований, то от дополнительного слоя можно и нужно отказаться.

    Мне не очень нравятся интерфейсы в качестве доменных объектов, т.к. при смене ОРМ будут проблемы сдублированием кода и его поддержкой.

    ОтветитьУдалить
  44. Занимательная дискуссия получилась..

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