Блог олександра биндю domain-driven design створення домену

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

Анемічна доменна модель

Якщо ваші доменні об'єкти є контейнерами даних і все, що в них є, це властивості get / set. то ви використовуєте анемічну доменну модель. Її особливістю є те, що доменний об'єкт не має поведінки.

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

  1. Користувач, який зареєструвався в системі, отримує лист з посиланням на підтвердження реєстрації. Перейшовши за посиланням, він підтверджує свою реєстрацію і може заходити під своїм логіном і паролем в систему.
  2. Користувач може робити замовлення
  3. При цьому в особистому кабінеті він бачить загальну суму, на яку замовив. У загальній сумі поточних замовлень не враховуються вже завершені замовлення

Почнемо з анемічного моделі даних. У нас буде клас Account:

Кожен із сценаріїв роботи досить просто реалізувати:

Сценарій №1. Активація користувача

Сценарій №2. Додавання замовлення

Сценарій №3. Підрахунок загальної суми

Головне питання: де буде розташовуватися цей код?

Є найпростіше і неправильне рішення. Ми будемо писати цей код прямо в обробниках на aspx-сторінках або WinForms:

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

Нерозумно дублювати код, а потім витрачати багато часу на виправлення одного зміненого бізнес-вимоги.

Все-таки дублювати не будемо. Ми винесемо код реалізації наших сценаріїв в клас зі звучною назвою AccountHelper або AccountManager. Швидше за все цей клас буде без стану, а тому статичним.

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

У таких класів безліч недоліків. Наприклад, важко тестувати код, який використовує ці класи, тому що вони статичні. Вони роблять код сильно пов'язаним, тому що порушують принцип інверсії залежностей. Дуже часто з одного Helper 'а викликають інші Helper' и. У підсумку, граф залежностей нагадує павутину з зв'язків.

До того ж, це рішення має усіма недоліками наступного.

Почнемо боротися з сильною пов'язаністю в коді. Зробимо все правильно і створимо клас AccountService з інтерфейсом IAccountService. Всі об'єкти, яким знадобиться активація або додавання замовлення будуть використовувати інтерфейс IAccountService замість конкретної реалізації. Це також допоможе нам у тестуванні коду.

Розібралися з обмеженістю і тестування. Уже крок вперед. Але я бачу ще дві проблеми.

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

У коді в будь-якому місці можна в обхід сервісу написати «свою активацію» користувача. Наприклад, взяти об'єкт Account з бази, виставити йому поле IsApproved в true і при цьому забути оновити поле ActivationDate. Теж саме стосується сценарію додавання замовлення. Можна викликати функцію Add у властивості Orders де завгодно і забути виставити поле Account у який додається замовлення. Це робить систему нестабільною. API додатка беззахисне перед користувачами системи. З таким підходом залишається тільки сподіватися, що програміст знайде потрібну йому функцію в IAccountService. а не стане винаходити свій підхід.

Помістимо всі ці функції в сам доменний об'єкт Account. Зверніть увагу на те, як змінилися модифікатори доступу до полів об'єкта:

Тепер домен нашого застосування дає користувачеві готове API, яка не требудет ні Helper 'ов, ні сервісів. До того ж ми вберігає користувача від помилок. Він вже не зможе активувати Account виставивши тільки IsApproved. Тепер функція Activate сама заповнить потрібні поля.

Отже, якщо функція оперує даними та об'єктами, які знаходяться всередині домену, то, швидше за все, треба залишати цю функцію всередині домену. Крім надійності коду, ви в добавок створите доменний мову для вашої програми.

ну, з приводу рішення номер 0 - мені стає не по собі, коли я згадую про те, що сам нерідко застосовував його. Це, звичайно, просто неприпустимо.

рішення номер 1 - може бути застосовано тільки з жорстко захистом логікою і тільки там, де клас AccountHelper доступний (привіт, капітан очевидність :)). У маленькому проекті цілком стерпне рішення.

Рішення номер 2 - по мені цілком підходить. Якщо у тебе бізнес логіка в окремій збірці і сценарії відпрацювання бізнес логіки можуть змінюватися (тобто існує або може існувати кілька реалізацій IAccountService) то чому б і ні. Але взагалі відповідним було б створити кілька інтерфейсів, кожен з яких підтримував би визначений набір операцій над сутностями - наприклад, IAccountViewer, IAccountActicvator (приклади, звичайно, надумані, але суть зрозуміла) - ці всі інтерфейси може реалізовувати і один клас, для клієнта це не важливо.

Взагалі вибір місця для зберігання логіки обробки сутностей: ентіті або сервіс - це філософське питання. Тут все залежить від пов'язаності логіки з доменом. Якщо потрібно щоб цю логіку використовували всі користувачі домену, то логіка розташовується в ентіті. Якщо це якась специфічна логіка взаємодії одного клієнта і домену - ви вибираєте прибрати логіку в сервіс. Якщо якийсь проміжний варіант - то це вже вирішувати виходячи зі здорового глузду.

Це називається ISP.

PS: для кожного клієнта домену передбачається свій сервісний рівень. Але, якщо клієнт використовує більшість методів якогось існуючого сервісного рівня то слід використовувати цей сервісний рівень. Ну і для уникнення дублювання в сервісах доцільно використовувати патерн комманда.

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

Наскільки я зрозумів, рішення №2 відноситься до частково-анемічного моделі. Такий собі компроміс: в ній є доменні методи, типу activate, що не залежать від зовнішніх класів, хоча і містять логіку. Решта ж винесені в Сервісний шар.

@Constantine
Дякую за доповнення, дуже цінно.

Цікаво буде подивитися на приклади:

> На додаток зіткнувся з проблемою ланцюга залежностей

Якщо буде багато коду, кидай на пошту.

А якщо трохи ускладнити ваш приклад, припустимо введемо нове бізнеc правило: до рахунку можна додавати замовлення, якщо у нього вже є замовлення в статусі IsComplete = true. Куди ми додамо цю перевірку? Очевидно, в метод AddOrder класу Account, тому що саме він відповідає у нас за додавання замовлення до рахунку.
Але в такому випадку якщо у нас після того як перевірка дала позитивний результат, але до того як це замовлення було додано - інший користувач додасть замовлення в статусі IsComplete = true, то тому перевірка вже пройшла, замовлення першого користувача буде спокійно доданий, а правило регламенту порушено. Очевидно що потрібно додавання і перевірку робити в транзакції СУБД, але транзакція СУБД - це не шар домену.
Як ви вийдете з подібної ситуації?

Тут залежить від навантаження додатки.

Найпростіший варіант поставити Lock в цьому місці.

Інший варіант при відкритті транзакції вказати Isolation Level, який не допускає такої ситуації.

А ви як вже намагалися вирішувати проблему?

NHibernate я не використовую, але ідею приблизно зрозумів.

Тільки чому "управління транзакцією здійснюється на рівні контролерів об'єктом UnitOfWork"? Ось приклад із замовленням і рахунком, адже по-суті, обов'язок перевірити правило, що у рахунку немає завершених замовлень - це обов'язок доменної області. І логічно помістити цю перевірку в метод AddOrder самого рахунку, обклавши її транзакцією за допомогою UoW. Якщо покласти цей обов'язок на контролер виходить він повинен знати як влаштований метод AddOrder. Якщо на контролер покласти саму перевірку - це буде означати, що рівень операційної логіки займається питаннями предметної логіки.

> І логічно помістити цю перевірку в метод AddOrder самого рахунку, обклавши її транзакцією за допомогою UoW

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

Ви мабуть не зрозуміли питання, як це він не буде знати про UoW, якщо UoW буде викликаний всередині цього методу. Якщо ж сам виклик методу в контролері буде обкладений UoW, то мається на увазі, що викликає сторона повинна представляти, що відбувається всередині методу щоб мати сенс обкласти його виклик в UoW.

UoW створюється в котролері, в доменному об'єкті немає посилання на UoW. Наприклад, метод client.Lock () змінює стан об'єкта. Зміна ці даних UoW відстежує автоматично. Це вміє робити, наприклад, NHibernate.

хм. а коменти-то навіщо терти.

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

UoW створюється на рівні контролера, сутності беруться з UoW і кладуться назад в UoW. Плюс UoW відстежує зміни в сутності. Так, це вже реалізовано в багатьох ORM, але ви можете дивлячись на теорію, написати свою реалізацію.

Доброго дня. Є небольшрй питання непорозуміння. Получаетя ви проектуєте (пишіть) модель предметної облати (DDD). а потім прив'язуєте до неї маппинг NHibernate?

Так все вірно. Причому маппинг потрібен, тільки якщо БД потрібна відразу. Для початку розробки можна використовувати БД в пам'яті або текстові файли. Маппінг вже наступний крок.

Добрий день. У нас так: Є збірка DataAccess з конфігурацією, Маппінг і т.п. (Використовуємо NHibernate); Є збірка DomainModel. DA має посилання на DM. Питання наступний: що робити з логікою, яка потребує будь-яких даних з БД? Я ж не можу в функціях доменного об'єкта використовувати ORM. Доводиться створювати 3ую збірку Services, але це не гуд (якраз те, що описувалося вище в статті). Як бути?

Те, що створюється окремий шар для маніпулювання об'єктами - це нормально, інакше не вийде. Просто це можуть бути сервіси, в кожному по десятку методів, а можуть бути команди (шаблон Command), де кожна команда буде відповідати за свою частину логіки.

Олександр, можна і чи правильно використовувати доменні об'єкти в підході code first для EF? І якщо спочатку це анемічні доменні об'єкти в подальшому отрефакторіть їх відповідно до "Рішення 3" (додати API і змінити модифікатори доступу у сеттерів)?
І ще питання:
"Функцій типу AddOrder і CalculateOrdersSum буде досить багато. Через пів року розробки інтерфейс IAccountService виростить до 40-50 функцій." Чи можуть взагалі стати в нагоді такі інтерфейси IAccountService і де?

Денис, можна, для цього немає ніяких обмежень. У нас є проекти на EF, ми там у всю використовуємо.

"Чи можуть взагалі стати в нагоді такі інтерфейси IAccountService і де?"

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

Припустимо в контексті формування замовлення для нас це все не має значення. Тоді робити дві сутність ProductForOrderContext і ProductForEditingContext? У такому випадку кількість сутностей може значно разростататься, так і в другому випадку "сутність" знову таки буде анемічного, по суті навіть просто DTO. Загалом не зрозуміло.

"Друге - формує доменний мову і не є проблемою."

Дуже навіть є проблемою, навіть кількома:

§ Змішується в загальну купу логіка наприклад - для гостя, зареєстрованого користувача, прівелігірованого користувача, адміністратора.

§ Клас згодом стають God-object-му.

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