4. Опрацювання складної бізнес-логіки
4. Опрацювання складної бізнес-логіки
https://github.com/arakviel/library-ddd-csharp
У попередньому розділі були розглянуті два патерни для роботи з відносно простою бізнес-логікою: транзакційний сценарій (transaction script) та активний запис (active record). У цьому розділі ми продовжимо тему реалізації бізнес-логіки, зосередившись на патерні моделі предметної області (domain model), який орієнтований на складнішу бізнес-логіку.
Передісторія
Патерн моделі предметної області, як і патерни транзакційного сценарію та активного запису, вперше був описаний у книзі Мартіна Фаулера (Martin Fowler) «Patterns of Enterprise Application Architecture». Завершуючи обговорення цього патерна, Фаулер зазначив:
«Зараз Ерік Еванс (Eric Evans) пише книгу про створення моделей предметної області».
Згадана книга, «Domain-Driven Design: Tackling Complexity in the Heart of Software», стала основною працею Еванса. У своїй книзі автор представляє набір патернів, що спрямовані на тісний зв’язок коду з базовою моделлю предметної області бізнесу: агрегати, об’єкти-значення, репозиторії тощо. Ці патерни є продовженням ідей, які Фаулер описав у своїй книзі, та формують ефективний інструментарій для реалізації патерна моделі предметної області.
Тактичні засоби предметно-орієнтованого проєктування
Щоб уникнути хибного уявлення, ніби реалізація ідей предметно-орієнтованого проєктування обов’язково передбачає використання цих патернів для реалізації бізнес-логіки, ми дотримуватимемося вихідної термінології Фаулера. У цьому контексті патерн і надалі визначатиметься як «модель предметної області».
Модель предметної області (доменна модель)
Патерн моделі предметної області призначений для роботи зі складною бізнес-логікою. Він фокусується не на простих CRUD-операціях, а на вирішенні питань, пов’язаних із переходами між станами, бізнес-правилами та інваріантами (тобто незмінними правилами).
Приклад: система технічної підтримки користувачів
Уявімо, що ми впроваджуємо систему технічної підтримки користувачів (help desk). Ось приклад вимог, які описують логіку управління життєвими циклами заявок у службу підтримки:
- Створення заявки: клієнти відкривають заявки, описуючи проблеми, з якими вони стикаються.
- Переписка: і клієнт, і співробітник служби підтримки додають повідомлення, які можна переглянути у відповідній заявці.
- Пріоритет заявки: кожна заявка має пріоритет — низький, середній, високий або терміновий.
- Час для вирішення: співробітник служби підтримки повинен запропонувати рішення в межах встановленого терміну (SLA), що залежить від пріоритету заявки.
- Прострочення: якщо співробітник не відповідає вчасно, клієнт може передати заявку керівнику співробітника.
- Ескалація: передача заявки на розгляд у вищу інстанцію скорочує ліміт часу для відповіді співробітника на 33%.
- Переназначення: якщо співробітник не відкрив ескаловану заявку протягом половини відведеного часу, вона автоматично переназначається іншому співробітнику.
- Автоматичне закриття: заявки автоматично закриваються, якщо клієнт не відповідає на питання співробітника протягом семи днів.
- Обмеження для закриття: ескаловані заявки не можуть бути закриті автоматично або самим співробітником. Це може зробити лише клієнт або керівник співробітника.
- Повторне відкриття: клієнт може повторно відкрити закриту заявку тільки у випадку, якщо вона була закрита не більше семи днів тому.
Виклики реалізації
Ці вимоги формують складну мережу залежностей між правилами, які впливають на логіку управління життєвим циклом заявки у службу підтримки. Це вже значно складніше за звичайний CRUD-екран, описаний у попередньому розділі.
Проблеми використання активного запису
Спроба реалізувати цю логіку за допомогою патерна активний запис може призвести до:
- Множинних повторів: код буде повторюватися в різних частинах системи.
- Несумісних станів: через неправильну реалізацію деяких бізнес-правил система може опинитися у некоректному стані.
Реалізація моделі предметної області
Модель предметної області — це об’єктна модель, яка об'єднує в собі як поведінку, так і дані. Основними будівельними блоками цієї моделі є тактичні патерни DDD:
- Агрегати (aggregates),
- Об’єкти-значення (value objects),
- Події предметної області (domain events),
- Доменні сервіси (domain services).
Загальна ідея
Усі ці патерни мають спільну тему: вони ставлять бізнес-логіку на перше місце. Розглянемо, як модель предметної області допомагає вирішувати задачі проєктування.
Складність
Бізнес-логіка предметної області вже сама по собі є складною. Тому об'єкти, які її моделюють, не повинні вносити додаткову, ненавмисну складність.
Основні вимоги:
- Відсутність інфраструктурних проблем:
- Об'єкти моделі не повинні містити реалізацію запитів до баз даних чи інших зовнішніх компонентів системи.
- Простота: - Об'єкти повинні залишатися простими звичайними об'єктами, які реалізують бізнес-логіку, без прямої залежності від інфраструктури чи платформ.
Єдина мова
Зосередженість на бізнес-логіці полегшує використання поняття єдиної мови (ubiquitous language) в рамках обмеженого контексту (bounded context).
Що це означає:
- Код стає "читабельним" і "зрозумілим" для експертів предметної області.
- Логіка системи узгоджується з ментальними моделями, які використовують ці експерти.
Будівельні блоки
Основні тактичні патерни, запропоновані DDD, включають:
- Об’єкти-значення (Value Objects):
- Не мають ідентичності, їх значення визначає їхню сутність.
- Використовуються для зберігання атрибутів, що не змінюються.
- Агрегати (Aggregates):
- Група об'єктів із чітким кореневим об'єктом (aggregate root), через який здійснюється доступ до всієї групи.
- Доменні сервіси (Domain Services):
- Використовуються для реалізації логіки, яка не вписується в конкретні об'єкти.
Ці будівельні блоки дозволяють створювати гнучкі, стійкі та зрозумілі моделі, що відповідають складній бізнес-логіці.
Об’єкт-значення
Об’єкт-значення (value object) — це об’єкт, який можна ідентифікувати за складовими його значеннями. Розглянемо, наприклад, об’єкт кольору:
class Color {
int red;
int green;
int blue;
}
Кольори визначаються комбінацією значень трьох полів: червоного, зеленого та синього. Зміна значення хоча б одного з полів призведе до утворення нового кольору. Два різних кольори не можуть мати однакових значень. Крім того, два екземпляри одного й того ж кольору повинні мати однакові значення. Таким чином, для ідентифікації кольорів явне ідентифікаційне поле не потрібне.
Приклад проблеми
Поле colorId, показане на рис. 6.1, не лише є надлишковим, але й створює підґрунтя для помилок. Наприклад, можна створити два рядки з однаковими значеннями red, green і blue, але порівняння значень colorId не покаже, що вони позначають один і той самий колір.
Ненадлишкове поле
| colorId | red | green | blue |
|---|---|---|---|
| 1 | 255 | 255 | 0 |
| 2 | 0 | 128 | 128 |
| 3 | 0 | 0 | 255 |
| 4 | 0 | 0 | 255 |
Єдина мова
Якщо для представлення понять предметної області покладатися виключно на елементарні типи даних стандартної бібліотеки мови, такі як рядки, цілі числа чи словники, це буде вважатися одержимістю примітивами (Primitive Obsession).
Розглянемо, наприклад, наступний клас:
class Person {
private int _id;
private string firstName;
private string lastName;
private string landlinePhone;
private string _mobilePhone;
private string _email;
private int _heightMetric;
private string _countryCode;
public Person(...) { ... }
static void Main(string[] args) {
var dave = new Person(
id: 30217,
firstName: "Dave",
lastName: "Ancelovici",
landlinePhone: "023745001",
mobilePhone: "0873712503",
email: "dave@learning-ddd.com",
heightMetric: 180,
countryCode: "BG"
);
}
}
var heightMetric = Height.Metric(180);
var heightImperial = Height.Imperial(5, 3);
var string1 = heightMetric.ToString(); // "180cm"
var string2 = heightImperial.ToString(); // "5 feet 3 inches"
var string3 = heightMetric.ToImperial().ToString(); // "5 feet 11 inches"
var firstIsHigher = heightMetric > heightImperial; // true
Об'єкт-значення PhoneNumber може інкапсулювати логіку аналізу рядкового значення, його перевірки та витягування різних атрибутів номера телефону, наприклад, країни, до якої він належить, і типу номера телефону — стаціонарний чи мобільний:
var phone = PhoneNumber.Parse("+359877123503");
var country = phone.Country; // "BG"
var phoneType = phone.PhoneType; // "MOBILE"
var isValid = PhoneNumber.IsValid("+972120266680"); // false
Наступний приклад демонструє ефективність застосування об'єкта-значення, коли в ньому інкапсулюється вся бізнес-логіка, яка маніпулює даними та створює нові екземпляри об'єкта-значення:
var red = Color.FromRGB(255, 0, 0);
var green = Color.Green;
var yellow = red.MixWith(green);
var yellowString = yellow.ToString(); // "#FFFF00"
З наведених вище прикладів можна зрозуміти, що об'єкти-значення усувають необхідність у різноманітних угодах, таких як потреба пам'ятати, що цей рядок — адреса електронної пошти, а ось ця — номер телефону, і при цьому роблять використання об'єктної моделі менш схильним до помилок та більш інтуїтивно зрозумілим.
Реалізація
Оскільки зміна будь-якого з полів об'єкта-значення призводить до появи іншого значення, об'єкти-значення реалізуються як незмінні (immutable) об'єкти. Зміна одного з полів об'єкта-значення концептуально створює інше значення — інший екземпляр об'єкта-значення. Тому, коли виконана дія призводить до нового значення, як у наступному випадку, де використовується метод MixWith, вона не змінює вихідний екземпляр, а створює та повертає новий екземпляр:
public class Color
{
public readonly byte Red;
public readonly byte Green;
public readonly byte Blue;
public Color(byte r, byte g, byte b)
{
this.Red = r;
this.Green = g;
this.Blue = b;
}
public Color MixWith(Color other)
{
return new Color(
r: (byte)Math.Min(this.Red + other.Red, 255),
g: (byte)Math.Min(this.Green + other.Green, 255),
b: (byte)Math.Min(this.Blue + other.Blue, 255)
);
}
}
Оскільки рівність об'єктів-значень базується на складових їх значеннях, а не на полі ідентифікатора чи посилання, важливо перевизначити та правильно реалізувати перевірки на рівність. Наприклад, у мові C#:
public override bool Equals(object obj)
{
if (obj is Color other)
{
return this.Red == other.Red &&
this.Green == other.Green &&
this.Blue == other.Blue;
}
return false;
}
public override int GetHashCode()
{
return HashCode.Combine(Red, Green, Blue);
}
public class Color
{
public override bool Equals(object obj)
{
var other = obj as Color;
return other != null &&
this.Red == other.Red &&
this.Green == other.Green &&
this.Blue == other.Blue;
}
public static bool operator ==(Color lhs, Color rhs)
{
if (Object.ReferenceEquals(lhs, null))
return Object.ReferenceEquals(rhs, null);
return lhs.Equals(rhs);
}
public static bool operator !=(Color lhs, Color rhs)
{
return !(lhs == rhs);
}
public override int GetHashCode()
{
return ToString().GetHashCode();
}
}
String для представлення специфічних для предметної області значень з базової бібліотеки суперечить концепції об'єктів-значень, у .NET, Java та інших мовах рядковий тип реалізований саме як об'єкт-значення. Рядки незмінні, оскільки всі операції з ними призводять до створення нового екземпляра. Крім того, у рядковому типі інкапсулюється досить різноманітна поведінка, що призводить до створення нових екземплярів шляхом маніпуляцій зі значеннями однієї чи кількох рядків. Це обрізка, об'єднання кількох рядків, заміна символів, виділення підрядків та інші методи.Коли слід використовувати об'єкти-значення
З точки зору бізнес-області, корисним практичним правилом є використання об'єктів-значень для елементів предметної області, які описують властивості інших об'єктів. Зокрема, це стосується властивостей сутностей, розглянутих у наступному розділі. У раніше представлених прикладах для опису людини використовувались об'єкти-значення: її ідентифікатор, ім'я, номери телефонів, адреси електронної пошти і т. д. Інші приклади використання об'єктів-значень можуть включати різні стани, паролі та інші поняття, що відносяться до предметної області бізнесу і допускають ідентифікацію за їх значеннями, таким чином, не вимагаючи явної присутності поля ідентифікації.
using System;
// Value Objects
public record PersonId(int Value)
{
public PersonId
{
if (Value <= 0)
throw new ArgumentException("ID must be greater than 0.");
}
}
public record Name(string Value)
{
public Name
{
if (string.IsNullOrWhiteSpace(Value))
throw new ArgumentException("Name cannot be empty.");
}
}
public record PhoneNumber(string Value)
{
public string Country { get; }
public string PhoneType { get; }
public PhoneNumber
{
if (string.IsNullOrWhiteSpace(Value) || !System.Text.RegularExpressions.Regex.IsMatch(Value, "^\\+\\d{10,15}$"))
throw new ArgumentException("Invalid phone number format.");
Country = ParseCountryCode(Value);
PhoneType = DeterminePhoneType(Value);
}
private static string ParseCountryCode(string phoneNumber)
{
// Example logic, you can extend this based on real requirements
if (phoneNumber.StartsWith("+359")) return "BG";
return "Unknown";
}
private static string DeterminePhoneType(string phoneNumber)
{
// Example logic, you can extend this based on real requirements
if (phoneNumber.StartsWith("+3598")) return "MOBILE";
return "LANDLINE";
}
public static bool IsValid(string phoneNumber)
{
return System.Text.RegularExpressions.Regex.IsMatch(phoneNumber, "^\\+\\d{10,15}$");
}
public static PhoneNumber Parse(string phoneNumber)
{
return new PhoneNumber(phoneNumber);
}
}
public record Email(string Value)
{
public Email
{
if (string.IsNullOrWhiteSpace(Value) || !Value.Contains("@"))
throw new ArgumentException("Invalid email address.");
}
}
public class Height
{
private readonly int _valueInCm;
private Height(int valueInCm)
{
if (valueInCm <= 0 || valueInCm > 300)
throw new ArgumentException("Height must be between 1 and 300 cm.");
_valueInCm = valueInCm;
}
public static Height Metric(int cm)
{
return new Height(cm);
}
public static Height Imperial(int feet, int inches)
{
if (feet < 0 || inches < 0 || inches >= 12)
throw new ArgumentException("Invalid feet or inches value.");
int totalInches = (feet * 12) + inches;
int cm = (int)(totalInches * 2.54);
return new Height(cm);
}
public Height ToImperial()
{
int totalInches = (int)(_valueInCm / 2.54);
int feet = totalInches / 12;
int inches = totalInches % 12;
return Imperial(feet, inches);
}
public override string ToString()
{
int totalInches = (int)(_valueInCm / 2.54);
if (totalInches % 12 == 0)
return $"{_valueInCm}cm";
int feet = totalInches / 12;
int inches = totalInches % 12;
return $"{feet} feet {inches} inches";
}
public static bool operator >(Height left, Height right)
{
return left._valueInCm > right._valueInCm;
}
public static bool operator <(Height left, Height right)
{
return left._valueInCm < right._valueInCm;
}
}
public record CountryCode(string Value)
{
public CountryCode
{
if (string.IsNullOrWhiteSpace(Value) || Value.Length != 2)
throw new ArgumentException("Country code must be a valid 2-letter ISO code.");
}
}
// Entity
public class Person
{
public PersonId Id { get; }
public Name FirstName { get; }
public Name LastName { get; }
public PhoneNumber LandlinePhone { get; }
public PhoneNumber MobilePhone { get; }
public Email Email { get; }
public Height HeightMetric { get; }
public CountryCode CountryCode { get; }
public Person(
PersonId id,
Name firstName,
Name lastName,
PhoneNumber landlinePhone,
PhoneNumber mobilePhone,
Email email,
Height heightMetric,
CountryCode countryCode)
{
Id = id;
FirstName = firstName;
LastName = lastName;
LandlinePhone = landlinePhone;
MobilePhone = mobilePhone;
Email = email;
HeightMetric = heightMetric;
CountryCode = countryCode;
}
static void Main(string[] args)
{
var heightMetric = Height.Metric(180);
var heightImperial = Height.Imperial(5, 11);
Console.WriteLine(heightMetric.ToString()); // "180cm"
Console.WriteLine(heightImperial.ToString()); // "5 feet 11 inches"
Console.WriteLine(heightMetric.ToImperial().ToString()); // "5 feet 11 inches"
Console.WriteLine(heightMetric > heightImperial); // false
var phone = PhoneNumber.Parse("+359877123503");
Console.WriteLine(phone.Country); // "BG"
Console.WriteLine(phone.PhoneType); // "MOBILE"
Console.WriteLine(PhoneNumber.IsValid("+972120266680")); // false
}
}
Сутності
Сутність є протилежністю об'єкта-значення. Для неї потрібно явно вказане поле ідентифікації, щоб відрізняти різні екземпляри об'єкта. Елементарним прикладом сутності є людина. Розглянемо наступний клас:
class Person
{
public Name Name { get; set; }
public Person(Name name)
{
this.Name = name;
}
}
Цей клас містить лише одне поле: name (об'єкт-значення). Але такий дизайн неоптимальний, оскільки різні люди можуть бути однофамільцями та мати абсолютно однакові імена. Це, звичайно, не робить їх однією і тією ж особою. Отже, для правильної ідентифікації людей необхідно поле ідентифікації:
class Person
{
public readonly PersonId Id;
public Name Name { get; set; }
public Person(PersonId id, Name name)
{
this.Id = id;
this.Name = name;
}
}
У попередньому коді було введено поле ідентифікації Id типу PersonId. А PersonId — це об'єкт-значення, і в ньому можна використовувати будь-яких базових типів даних, що відповідають потребам предметної області. Наприклад, ідентифікатор Id може бути GUID-ідентифікатором, числом, рядком чи значенням, залежним від предметної області, наприклад, номером соціального страхування.
Це підводить нас до другого концептуального відмінності між об'єктами-значеннями та сутностями.

На відміну від об'єктів-значень, сутності не є незмінними та можуть бути змінені очікуваним чином. Інша важлива різниця між сутностями та об'єктами-значеннями полягає в тому, що об'єкти-значення описують властивості сутності. Раніше в цій главі вже згадувалась сутність Person з двома об'єктами-значеннями, що описують кожен екземпляр: Personid та Name.
Агрегати
Агрегат — це також сутність, для якої потрібне явне поле ідентифікації, і очікується, що її стан протягом життєвого циклу екземпляра буде змінюватися. Але це набагато більш широке поняття, ніж просто сутність. Метою цього патерну є захист узгодженості її даних. Оскільки дані агрегату можуть змінюватися, існують наслідки та проблеми, які патерн повинен вирішити для забезпечення узгодженості його стану.
Забезпечення узгодженості
Оскільки стан агрегату може змінюватися, це відкриває багато можливостей для пошкодження його даних. Щоб забезпечити узгодженість даних, патерн агрегату проводить чітку межу між агрегатом і «зовнішнім світом»: агрегат є межою забезпечення узгодженості. Логіка агрегату повинна перевіряти всі вхідні модифікації і гарантувати несуперечливість змін його бізнес-правилам.
Методи зміни стану, представлені в відкритому інтерфейсі агрегату, часто називаються командами (command), наприклад, «командою зробити щось».
Команда може бути реалізована двома способами. По-перше, її можна реалізувати як простий відкритий метод агрегатного об'єкта:
public class Ticket
{
public void AddMessage(Userid from, string body)
{
var message = new Message(from, body);
_messages.Append(message);
}
}
Як альтернатива, команда може быть представлена як об'єкт-параметр, що інкапсулює всі вхідні дані, необхідні для виконання команди:
public class Ticket
{
public void Execute(AddMessage cmd)
{
var message = new Message(cmd.from, cmd.body);
_messages.Append(message);
}
}
Як саме команди будуть виражатися в коді агрегата, залежить від уподобань розробника. Особисто я віддаю перевагу більш явному визначенню структур команд і їх поліморфній передачі відповідному методу Execute.
Операції над агрегатами
Відкритий інтерфейс агрегата відповідає за перевірку вхідних значень і забезпечення дотримання всіх відповідних бізнес-правил та інваріантів. Ця чітка межа також гарантує, що вся бізнес-логіка, пов'язана з агрегатом, реалізована в одному місці — в самому агрегаті.
Це робить шар додатку (також відомий як сервісний шар) досить простим: йому потрібно лише завантажити поточний стан агрегата, виконати необхідну дію, зберегти змінений стан і повернути результат операції викликаючому коду.
public ExecutionResult Escalate(Ticketid id, EscalationReason reason)
{
try
{
var ticket = _ticketRepository.Load(id);
var cmd = new Escalate(reason);
ticket.Execute(cmd);
_ticketRepository.Save(ticket);
return ExecutionResult.Success();
}
catch (ConcurrencyException ex)
{
return ExecutionResult.Error(ex);
}
}
Перевірка конкурентного доступу
У такому випадку другий процес повинен бути сповіщений про те, що стан, на якому він базував свої рішення, застарілий, і йому потрібно повторити свою операцію.
Управління конкурентним доступом у базі даних
Отже, база даних, що використовується для зберігання агрегатів, повинна підтримувати управління конкурентним доступом. У найпростішій формі агрегат повинен містити поле версії, значення якого буде зростати після кожного оновлення:
class Ticket
{
Ticketid _id;
int version;
}
При фіксації зміни в базі даних потрібно переконатися, що перезаписувана версія відповідає тій, що була спочатку прочитана. Наприклад, у коді на SQL:
01 UPDATE tickets
02 SET ticket_status = @new_status,
03 agg_version = agg_version + 1
04 WHERE ticket_id=@id and agg_version=@expected_version;
Цей SQL-оператор застосовує зміни, внесені в стан екземпляра агрегата (рядок 2), і збільшує значення його лічильника версій (рядок 3), але це відбувається лише в тому випадку, якщо поточна версія дорівнює тій, що була прочитана до застосування змін (рядок 4).
Границя транзакції
Жодна системна операція не може передбачати проведення мультиагрегатної транзакції. Зміна стану агрегата може бути зафіксована тільки індивідуально, за одну транзакцію бази даних має змінюватися лише один агрегат.
Одиничність агрегата в транзакціях
Ієрархія сутностей
Як уже зазначалося в цій глави, сутності використовуються виключно як частина агрегата, а не як самостійний патерн. Розглянемо фундаментальну різницю між сутностями та агрегатами, а також чому сутності є будівельними блоками агрегата, а не загальною моделлю предметної області.
Є бізнес-сценарії, коли кілька об'єктів повинні мати загальну транзакційну межу, наприклад, коли одночасно змінюються два об'єкти, або коли бізнес-правила одного об'єкта залежать від стану іншого.
У DDD (Domain-Driven Design) передбачається, що дизайн системи має визначатися її предметною областю. Агрегати не є винятком. Для підтримки змін кількох об'єктів, які повинні бути застосовані в рамках однієї атомарної транзакції, патерн агрегата нагадує ієрархію сутностей, де, як показано на рисунку 6.3, всі вони без винятку мають загальну транзакційну узгодженість.
Ієрархія містить як сутності, так і об'єкти-значення, і якщо вони пов'язані бізнес-логікою предметної області, то всі вони належать до одного і того ж агрегата.

Ось чому патерн називається «агрегат»: він об'єднує бізнес-сутності та об'єкти-значення, які знаходяться в межах однієї й тієї ж транзакційної межі.
Приклад бізнес-правила, що охоплює кілька сутностей:
У наступному прикладі коду демонструється бізнес-правило, яке охоплює одразу кілька сутностей, що входять до меж агрегата:
«Якщо агент не відкрив ескаловану заявку протягом 50% часу відповіді, вона автоматично переназначається іншому агенту»
public class Ticket
{
List<Message> _messages;
public void Execute(EvaluateAutomaticActions cmd)
{
if (this.IsEscalated && this.RemainingTimePercentage < 0.5 &&
GetUnreadMessagesCount(for: AssignedAgent) > 0)
{
_agent = AssignNewAgent();
}
}
public int GetUnreadMessagesCount(Userid id)
{
return _messages.Where(x => x.To == id && !x.WasRead).Count();
}
}
У методі перевіряється значення заявки, щоб визначити, чи була вона ескалована, а також чи залишилось менше часу на обробку, ніж задано порогом у 50% (рядок 9). Крім того, перевіряються повідомлення, які ще не були прочитані поточним агентом (рядок 10). Якщо всі умови виконуються, ініціюється переназначення заявки іншому агенту.
Гарантія атомарності операцій
Агрегат гарантує, що всі перевірки виконуються на строго узгоджених даних, і що ці дані не змінюються після завершення перевірки, оскільки всі зміни в даних агрегата будуть виконані в межах однієї атомарної транзакції.
Силки на інші агрегати
Оскільки всі об'єкти, що містяться в агрегаті, мають одну й ту саму транзакційну межу, якщо агрегат стане занадто великим, можуть виникнути проблеми з продуктивністю та масштабованістю.
Вся інформація, яка може бути узгоджена з часом (кінцева узгодженість — eventual consistency), повинна бути поза межами агрегата, наприклад, як частина іншого агрегата.

Слід дотримуватися того правила, щоб агрегати були як можна меншими і включали тільки ті об'єкти, які відповідно до бізнес-логіки агрегата повинні знаходитися в строго узгодженому стані:
public class Ticket
{
private Userid _customer;
private List<Productid> _products;
private Userid _assignedAgent;
private List<Message> _messages;
}
В попередньому прикладі агрегат Ticket містить список повідомлень, що належать до меж агрегата. А ось клієнт, набір продуктів, що відносяться до заявки, і призначений агент не належать агрегату, і тому посилання на них йдуть через їх ідентифікатори.
Використання посилань на зовнішні агрегати
Замисел використання посилання на зовнішні агрегати за ідентифікатором полягає в тому, щоб підтвердити, що ці об'єкти не належать до меж агрегата, і гарантувати наявність у кожного агрегата своєї власної транзакційної межі.
Визначення належності сутності до агрегата
Щоб вирішити, чи належить сутність до агрегата чи ні, слід перевірити, чи містить агрегат бізнес-логіку, яка може призвести до недопустимого стану системи при роботі з даними за принципом "согласованості в кінцевому рахунку".
У цьому випадку можна з упевненістю сказати, що велика кількість заявок була б переназначена без необхідності. Це, безумовно, погіршило б стан системи. Отже, дані про повідомлення повинні знаходитися в межах агрегата.
Корінь агрегата
Нам уже відомо, що стан агрегата можна змінити лише шляхом виконання однієї з його команд. Оскільки агрегат є ієрархією сутностей, як показано на малюнку 6.5, в якості загальнодоступного інтерфейсу агрегата — його кореня, повинна бути призначена тільки одна з них.

Розглянемо наступний фрагмент агрегату Ticket:
public class Ticket
{
List<Message> _messages;
public void Execute(AcknowledgeMessage cmd)
{
var message = _messages
.Where(x => x.Id == cmd.id)
.First();
message.WasRead = true;
}
}
В цьому прикладі агрегат надає команду, що дозволяє позначити конкретне повідомлення як прочитане. Хоча операція змінює екземпляр об'єкта Message, він доступний тільки через корінь агрегату: Ticket.
Події предметної області
https://github.com/arakviel/order-domain-events-example-ddd-csharp
Подія предметної області — це повідомлення з описом важливої події, яка сталася в бізнес-області. Наприклад:
- Заявка призначена.
- Заявка ескалована.
- Повідомлення отримано.
Мета події предметної області (domain event) — це дати опис тому, що сталося в предметній області, і надати всі необхідні дані, пов'язані з подією. Наприклад, наступна подія предметної області повідомляє, що конкретна заявка була ескалована з вказівкою часу та причини, чому це сталося:
{
"ticket-id": "c9d286ff-3bca-4f57-94d4-4d4e490867d1",
"event-id": 146,
"event-type": "ticket-escalated",
"escalation-reason": "missed-sla",
"escalation-time": 1628970815
}
Як і майже в усьому в програмуванні, присвоєння імен має велику важливість. Слід переконатися, що імена подій предметної області точно відображають те, що відбувається в предметній області.
Події предметної області є частиною публічного інтерфейсу агрегату, який публікує події своєї предметної області. Як показано на малюнку 6.6, інші процеси, агрегати або навіть зовнішні системи можуть підписуватися на події предметної області та виконувати свою власну логіку у відповідь на них.
У наступному фрагменті коду агрегату Ticket створюється екземпляр нової події предметної області (рядок 12), яка додається до набору подій предметної області заявки (рядок 13):

public class Ticket
{
private List<DomainEvent> domainEvents;
public void Execute(RequestEscalation cmd)
{
if (!this.IsEscalated && this.RemainingTimePercentage <= 0)
{
this.IsEscalated = true;
var escalatedEvent = new TicketEscalated(_id, cmd.Reason);
_domainEvents.Append(escalatedEvent);
}
}
}
Універсальна мова (Ubiquitous Language)
Як сказав Ерік Еванс, код повинен бути побудований на тій самій мові, якою розмовляють розробники та експерти предметної області. Це має особливе значення під час реалізації складної бізнес-логіки.
Домени сервіси (Domain Services)
З часом можна зіткнутися з бізнес-логікою, яка або не належить жодному агрегату чи об'єкту-значенню, або має відношення одразу до кількох агрегатів. У таких випадках предметно-орієнтоване проектування пропонує реалізувати логіку як доменний сервіс.
Приклад:
Візьмемо приклад з агрегатом заявок. Уявімо, що у призначеного агента є обмежений термін для надання рішення клієнту. Цей термін залежить не тільки від даних заявки (її пріоритету та статусу ескалації), а й від політики відділу агента щодо встановленого терміну (SLA) для кожного пріоритету та графіка роботи агента (не слід очікувати, що агент відповість поза робочими годинами).
Логіка обчислення термінів відповіді вимагає отримання інформації одразу з кількох джерел: заявки, відділу агента та графіка роботи. Це ідеальний випадок для реалізації як служба предметної області.
public class ResponseTimeFrameCalculationService
{
public ResponseTimeFrame CalculateAgentResponseDeadline(UserId agentId,
Priority priority, bool escalated, DateTime startTime)
{
var policy = _departmentRepository.GetDepartmentPolicy(agentId);
var maxProcTime = policy.GetMaxResponseTimeFor(priority);
if (escalated)
{
maxProcTime = maxProcTime * policy.EscalationFactor;
}
var shifts = _departmentRepository.GetUpcomingShifts(agentId,
startTime, startTime.Add(policy.MaxAgentResponseTime));
return CalculateTargetTime(maxProcTime, shifts);
}
}
Доменні сервіси
Доменні сервіси спрощують координацію роботи кількох агрегатів. Однак важливо не забувати про обмеження агрегатів, що стосуються змін лише одного екземпляра агрегата за одну транзакцію бази даних. Доменно́ї сервіси не створюють лазівку для обходу цього обмеження. Правило одного екземпляра за транзакцію залишається в силі. Замість цього доменно́ї сервіси дозволяють реалізувати логіку обчислень, що потребує читання даних відразу з кількох агрегатів.
Управління складністю
На початку цієї глави вже говорилося, що патерни агрегатів та об'єктів-значень були введені як засоби подолання складності при реалізації бізнес-логіки. Давайте подивимося, чи це дійсно так.
У своїй книзі «The Choice» гуру управління бізнесом Еліяху М. Голдратт дає лаконічне, але ефектне визначення складності системи. За словами Голдратта, при розгляді складності системи основну увагу приділяють оцінці складності контролю за поведінкою системи та передбачення цього поведінки. Ці два аспекти відображаються у степенях свободи системи.
Степені свободи системи
Степені свободи системи — це опорні точки опису її стану. Розглянемо наступні два класи:
public class ClassA
{
public int A { get; }
public int B { get; }
public int C { get; }
public int D { get; }
public int E { get; }
}
public class ClassB
{
private int _a, _d;
public int A
{
get => _a;
set
{
_a = value;
B = value / 2;
C = value / 3;
}
}
public int B { get; }
public int C { get; }
public int D
{
get => _d;
set
{
_d = value;
E = value * 2;
}
}
public int E { get; private set; }
}
На перший погляд, клас ClassB здається дещо складнішим, ніж ClassA. У нього таке ж число змінних, але додатково до них виконуються обчислення. Так чи складніший він за ClassA? Розглянемо обидва класи з точки зору ступенів свободи.
ClassA? Відповідь — п'ять: це п'ять його змінних. Отже, у ClassA п'ять ступенів свободи.Скільки елементів даних потрібно для опису стану ClassB? Якщо подивитися на логіку присвоєння значень властивостям A та D, можна побачити, що значення B, C та E є функціями значень A та D. Якщо відомо значення A та D, можна вивести значення решти змінних. Отже, у ClassB лише дві ступені свободи. І для опису його стану потрібні лише два значення.
Який клас складніший для контролю і передбачення?
ClassA. Інваріанти, введені в ClassB, знижують його складність.Саме це роблять патерни агрегатів і об'єктів-значень: інкапсулюють інваріанти, тим самим знижуючи складність.
Уся бізнес-логіка, пов'язана з станом об'єкта-значення, знаходиться в його межах. Те саме стосується і агрегатів. Агрегат може бути змінений лише його власними методами. Його бізнес-логіка інкапсулює і захищає бізнес-інваріанти, тим самим знижуючи ступінь свободи.
Оскільки патерн моделі предметної області застосовується лише для піддоменів зі складною бізнес-логікою, можна з упевненістю сказати, що місце його застосування — це основний піддомен (core subdomain) — серце програмної системи.
Висновок
Патерн моделі предметної області призначений для випадків складної бізнес-логіки. Він складається з трьох основних будівельних блоків:
Будівельні блоки моделі предметної області справляються зі складною бізнес-логікою, інкапсулюючи її в межах об'єктів-значень і агрегатів. Відсутність можливості змінювати стан об'єктів ззовні гарантує, що вся відповідна бізнес-логіка реалізована в межах агрегатів і об'єктів-значень і не буде дублюватися на рівні додатка.
У наступній темі будуть розглянуті розширені способи реалізації патерну моделі предметної області, де тепер вже невід'ємною частиною моделі стане час.
Вправи
- Яке з наступних тверджень є правильним?
- A) Об'єкти-значення можуть містити лише дані.
- B) Об'єкти-значення можуть містити лише поведінку.
- C) Об'єкти-значення не підлягають змінам.
- D) Стан об'єктів-значень може змінюватися.
- Який загальний керівний принцип проектування меж агрегату?
- A) Агрегат може містити лише одну сутність, оскільки лише один екземпляр агрегату може бути включений в одну транзакцію бази даних.
- B) Агрегати повинні бути як можна меншими розмірами з незмінним виконанням вимог щодо узгодженості даних бізнес-області.
- C) Агрегати є ієрархіями сутностей. Тому, щоб забезпечити максимальну узгодженість даних системи, агрегати повинні розроблятися з максимально широким охопленням даних.
- D) Все залежить від конкретних обставин: для одних предметних областей краще створювати невеликі агрегати, а інші найбільш ефективно працюватимуть з агрегатами як можна більшого.
- Чому в одній транзакції може бути зафіксовано стан лише одного екземпляра агрегату?
- A) Щоб модель могла працювати під високим навантаженням.
- B) Для забезпечення правильних транзакційних меж.
- C) Такого вимоги немає; все залежить від бізнес-області.
- D) Щоб можна було працювати з базами даних, які не підтримують транзакції, що охоплюють одразу кілька записів, наприклад, з хранилищами типу «ключ-значення» і документоорієнтованими базами даних.
- Яке з наступних тверджень найкраще описує відносини між будівельними блоками моделі предметної області?
- A) Об'єкти-значення описують властивості сутностей.
- B) Об'єкти-значення можуть генерувати події предметної області.
- C) Агрегат містить одну або кілька сутностей.
- D) A і C.
- Яке з наступних тверджень про відмінності між активними записами та агрегатами є вірним?
- A) Активні записи містять лише дані, тоді як агрегати також містять поведінку. - B) Агрегат інкапсулює всю свою бізнес-логіку, а бізнес-логіка, що керує активним записом, може бути поза ним.
- C) Агрегати містять лише дані, а активні записи містять як дані, так і поведінку.
- D) Агрегат містить набір активних записів.
3. Реалізація простої бізнес-логіки
Бізнес-логіка — найважливіша частина програмного забезпечення. Саме вона є першочерговою метою створення програмного забезпечення. Користувацький інтерфейс системи може бути привабливим, а її база даних може бути неймовірно швидкою та масштабованою. Але якщо програмне забезпечення марне для бізнесу, це не більше ніж дорога демонстрація технологій.
5. Моделювання фактора часу. Подієво-орієнтована архітектура.
У попередньому розділі розглядався патерн моделі предметної області: його будівельні блоки, призначення та прикладний контекст.