Ddd

Обробка складної бізнес-логіки

Паттерн Domain Model із будівельними блоками: Value Objects, Entities, Aggregates, Domain Events та Domain Services в DDD

Обробка складної бізнес-логіки (Tackling Complex Business Logic)

Ключова ідея главиСкладна бізнес-логіка не може бути ефективно модельована через прості CRUD-операції. Для цього потрібна модель предметної області (Domain Model) — об'єктна модель, що поєднує дані і поведінку.

Передісторія (Background)

Паттерн Domain Model, як і паттерни Transaction Script та Active Record, був вперше представлений Martin Fowler у книзі «Patterns of Enterprise Application Architecture».

Зв'язок з DDDЗавершуючи обговорення Domain Model, Fowler writes:

«Зараз Eric Evans пише книгу про створення моделей предметної області.»

Книга «Domain-Driven Design: Tackling Complexity in the Heart of Software» стала основною працею Evans і представила набір паттернів для тісногов зв'язку коду з базової моделлю предметний області бізнесу.

Тактичні паттерни DDD

Evans представив паттерни, які є продовженням того, на чому зупинився Fowler:

Aggregates (Агрегати)

Value Objects (Об'єкти-значення)

Repositories

ТермінологіяПаттерни Evans часто називають «тактичними засобами DDD». Але щоб не створювати хибне уявлення, що DDD неминуче потребує використання цих паттернів, я дотримуюсь оригінальної термінології Fowler:
  • Паттерн: Domain Model
  • Будівельні блоки: Aggregates, Value Objects, Domain Events, Domain Services

Модель предметної області (Domain Model)

Loading diagram...
graph TB
    subgraph "Active Record ~ 30 lines"
        AR[Simple CRUD Logic]
    end

    subgraph "Domain Model ~ 300-3000 lines"
        DM1[Complex State Transitions]
        DM2[Business Rules]
        DM3[Invariants]
        DM4[Entity Hierarchies]
    end

    AR -->|Evolution| DM1

    style AR fill:#ffcdd2
    style DM1 fill:#c8e6c9
    style DM2 fill:#c8e6c9
    style DM3 fill:#c8e6c9
    style DM4 fill:#c8e6c9

Паттерн Domain Model призначений для складної бізнес-логіки. Замість роботи з простими CRUD-операціями, тут вирішуються питання:

  • 🔄 Складних переходів між станами
  • 📏 Бізнес-правил і інваріантів
  • 🧩 Взаємозв'язків між сутностями

Практичний приклад: Help Desk System

Припустимо, що ми впроваджуємо систему технічної підтримки користувачів (help desk). Розглянемо вимоги до життєвого циклу заявок:

Створення заявки

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

Комунікація

Клієнт і співробітник підтримки додають повідомлення — переписка відображається в заявці.

Пріоритет SLA

У кожної заявки є приоритет: низька, середня, висока або термінова.

Співробітник має запропонувати рішення в установлений термін (SLA) на основі пріоритету.

Ескалація

Якщо співробітник не відповість у SLA-термін, клієнт може передати заявку керівнику.

Наслідки ескалації:

  • SLA скорочується на 33%
  • Якщо співробітник не відкрив ескалована заявку за половину часу → автоматичне переприз́начення іншому співробітнику

Автоматичне закриття

Заявки автоматично закриваються, якщо клієнт не відповідає ≥7 днів.

Виняток: Ескальовані заявки не можуть бути закриті автоматично або співробітником — лише клієнтом або керівником.

Повторне відкриття

Клієнт може повторно відкрити закриту заявку тільки якщо вона була закрита не більше 7 днів тому.

СкладністьЦі вимоги утворюють заплутану мережу залежностей між різними правилами й впливають на життєвий цикл заявки.Це вже не CRUD-екран введення даних! Спроба реалізувати цю логіку через Active Records призведе до:
  • 🔁 Багаторазового повторення коду
  • ❌ Неузгодженого стану через неправильну реалізацію правил

Реалізація

Domain Model — це об'єктна модель, що включає і поведінку, і дані.

Loading diagram...
classDiagram
    class DomainModel {
        <<pattern>>
    }
    class ValueObject {
        Immutable
        Identified by values
    }
    class Entity {
        Has ID field
        Mutable
    }
    class Aggregate {
        Consistency boundary
        Transaction boundary
    }
    class DomainEvent {
        State change notification
    }
    class DomainService {
        Stateless business logic
    }

    DomainModel *-- ValueObject
    DomainModel *-- Aggregate
    DomainModel *-- DomainEvent
    DomainModel *-- DomainService
    Aggregate *-- Entity
    Entity *-- ValueObject

Будівельними блоками є тактичні паттерни DDD:

  1. Value Objects (Об'єкти-значення)
  2. Entities (Сутності)
  3. Aggregates (Агрегати)
  4. Domain Events (Події предметної області)
  5. Domain Services (Доменні сервіси)
Спільна темаВсі ці паттерни ставлять бізнес-логіку на перше місце.

Ключові принципи


Будівельні блоки

Об'єкт-значення (Value Object)

Об'єкт, який можна ідентифікувати за значеннями, що його складають.

Loading diagram...
classDiagram
    class Color {
        +byte Red
        +byte Green
        +byte Blue
        +Color MixWith(Color other)
        +string ToString()
    }

    note for Color "Ідентифікується значеннями\nБез явного ID\nНезмінний"

Приклад: Color

class Color
{
    private readonly byte _red;
    private readonly byte _green;
    private readonly byte _blue;

    public Color(byte r, byte g, byte b)
    {
        _red = r;
        _green = g;
        _blue = b;
    }
}
Ідентифікація за значеннямиКолір визначається композицією трьох значень: червоного, зеленого та синього.
  • Зміна будь-якого поля → новий колір
  • Два экземпляри одного кольору → однакові значення
  • ❌ Не потрібне явне поле ColorId

Антипаттерн: Primitive Obsession

Покладатися виключно на елементарні типи для представлення понять предметної області — одержимість примітивами (Primitive Obsession).

Рішення: Value Objects

// ✅ Використання Value Objects
class Person
{
    private readonly PersonId _id;
    private readonly Name _name;
    private readonly PhoneNumber _landline;
    private readonly PhoneNumber _mobile;
    private readonly EmailAddress _email;
    private readonly Height _height;
    private readonly CountryCode _country;

    public Person(...) { ... }
}

// Використання
var dave = new Person(
    id: new PersonId(30217),
    name: new Name("Dave", "Ancelovici"),
    landline: PhoneNumber.Parse("023745001"),
    mobile: PhoneNumber.Parse("0873712503"),
    email: Email.Parse("dave@learning-ddd.com"),
    height: Height.FromMetric(180),
    country: CountryCode.Parse("BG")
);

Практичні приклади Value Objects

Реалізація Value Objects

Immutability (Незмінність)

Зміна будь-якого поля створює інше значення → Value Objects є immutable.

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)
        );
    }
}
Переваги Immutability
  • ✅ Безпека від побічних ефектів
  • ✅ Потокобезпечність (thread-safe)
  • ✅ Передбачувана поведінка

Реалізація Equality

Р Equality базується на значеннях, а не на reference або ID:

public class Color
{
    // ... fields ...

    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();
    }
}
Примітка для C# 9.0+C# 9.0 record type автоматично реалізує equality на основі значень. Не потрібно перевизначати оператори.

Коли використовувати Value Objects?

Універсальна відповідь: При будь-якій можливості!Value Objects:
  • ✅ Роблять код виразнішим
  • ✅ Інкапсулюють логіку, що дублюється
  • ✅ Роблять код безпечнішим (immutability)
  • ✅ Потокобезпечні (thread-safe)

Практичне правило з бізнеспогляду:

Використовуйте Value Objects для елементів предметної області, що описують властивості інших об'єктів (особливо сутностей).

Приклади:

  • Властивості Person: PersonId, Name, PhoneNumber, Email
  • Стани системи: OrderStatus, TicketPriority
  • Паролі та credentials
  • Гроші (критично важливо!)
Money як Value ObjectВикористання примітивів для грошей:
  • ❌ Обмежує інкапсуляцію бізнес-логіки
  • ❌ Призводить до помилок округлення
  • ❌ Проблеми з точністю
Завжди використовуйте Money Value Object!

Сутності (Entities)

Протилежність Value Object. Потрібне явне поле ID для розрізнення екземплярів.

Проблема без ID

// ❌ Недостатньо для ідентифікації
class Person
{
    public Name Name { get; set; }

    public Person(Name name)
    {
        this.Name = name;
    }
}
ПроблемаDifferent люди можуть мати однакові імена. Це не робить їх однією особою!Потрібне поле ідентифікації.

Рішення: Explicit ID

// ✅ Entity з полем ідентифікації
class Person
{
    public readonly PersonId Id; // Immutable ID
    public Name Name { get; set; } // Mutable properties

    public Person(PersonId id, Name name)
    {
        this.Id = id;
        this.Name = name;
    }
}
Вимоги до ID
  1. Унікальність для кожного екземпляра Entity
  2. Незмінність протягом життєвого циклу об'єкта (за дуже рідкісними винятками)
Типи ID: GUID, int, string, або domain-specific value (наприклад, Social Security Number)
Loading diagram...
graph LR
    P1[Person<br/>ID: 123<br/>Name: John Smith]
    P2[Person<br/>ID: 456<br/>Name: John Smith]

    P1 -.->|"Different ID"| P2

    style P1 fill:#c8e6c9
    style P2 fill:#fff3e0

Відмінності Entities vs Value Objects

АспектValue ObjectEntity
ІдентифікаціяЗа значеннямиЗа полем ID
ImmutabilityНезміннийЗмінний (mutable)
EqualityНа основі значеньНа основі ID
РольОписує властивостіПредставляє об'єкт
ПрикладиColor, PhoneNumber, MoneyPerson, Order, Ticket
Зв'язокValue Objects описують властивості Entities.Раніше бачили Entity Person з Value Objects PersonId і Name.

Агрегати (Aggregates)

Ключова концепціяAggregate — це також Entity (потрібен ID, стан змінюється), але це набагато ширше поняття.Мета: Захист узгодженості даних.
Loading diagram...
graph TB
    External[External Code] -->|Commands| AR[Aggregate Root]
    AR -->|Validate & Execute| BL[Business Logic]
    BL -->|Modify| State[(Aggregate State)]

    State -.->|Read-only| External

    subgraph "Consistency Boundary"
        AR
        BL
        State
        E1[Entity 1]
        E2[Entity 2]
        VO[Value Objects]
    end

    style AR fill:#4caf50
    style External fill:#e3f2fd
    style BL fill:#fff3e0

Дотримання узгодженості (Consistency Enforcement)

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

Консистентність через межіAggregate проводить чітку межу між собою та зовнішнім світом:Aggregate є консистентністю boundary (consistency boundary).

Механізм забезпечення консистентності:

  1. ✅ Змінювати стан може тільки власна бізнес-логіка aggregate
  2. ❌ Зовнішнім процесам дозволено лише читання стану
  3. ✅ Зміни можливі тільки через публічні методи (commands)

Команди (Commands)

Методи зміни стану aggregate називаются командами.

Loading diagram...
sequenceDiagram
    participant C as Client
    participant A as Aggregate
    participant V as Validation
    participant S as State

    C->>A: ExecuteCommand(params)
    activate A
    A->>V: Validate business rules
    alt Invalid
        V-->>A: Reject
        A-->>C: Error/Exception
    else Valid
        V-->>A: OK
        A->>S: Modify state
        S-->>A: Updated
        A-->>C: Success
    end
    deactivate A
public class Ticket // Aggregate Root
{
    private TicketId _id;
    private Priority _priority;
    private TicketStatus _status;

    // ✅ Command: Public method
    public void Escalate(EscalationReason reason)
    {
        // Validate business rules
        if (_status == TicketStatus.Closed)
        {
            throw new InvalidOperationException("Cannot escalate closed ticket");
        }

        if (!CanEscalate())
        {
            throw new InvalidOperationException("Escalation not allowed");
        }

        // Modify state
        _status = TicketStatus.Escalated;
        _priority = IncreasePriority(_priority);
        _slaDeadline = RecalculateSLA(_slaDeadline, escalationReduction: 0.33);
    }

    // ❌ Private properties - можуть бути змінені тільки зсередини
    private Priority IncreasePriority(Priority current) { ... }
    private DateTime RecalculateSLA(DateTime original, double reduction) { ... }
}
Чому команди?Ім'я «команда» відображає спосіб виклику: «зробити щось».Приклади:
  • ticket.Escalate(reason)
  • order.Submit()
  • account.Withdraw(amount)

Transaction Boundary (Границя транзакції)

Золоте правилоОдин aggregate на транзакцію!Модифікація кількох aggregates в одній транзакції — ознака неправильних меж aggregate.

Чому це важливо?

  1. Concurrency — менші межі = менше конфліктів concurrent changes
  2. Performance — менший lock scope
  3. Scalability — незалежні aggregates можна масштабувати окремо

Посилання між Aggregates

Aggregates можуть посилатися один на одного тільки через ID, а не через прямі object references:

public class Ticket
{
    private TicketId _id;
    private AgentId _assignedAgent; // ✅ Reference by ID
    //private Agent _assignedAgent; // ❌ Direct object reference

    public void AssignToAgent(AgentId agentId)
    {
        _assignedAgent = agentId;
    }
}

Ієрархії сутностей (Entity Hierarchies)

Aggregate може містити кілька entities всередині себе.

Loading diagram...
graph TB
    AR[Order<br/>Aggregate Root]
    E1[OrderLine 1<br/>Entity]
    E2[OrderLine 2<br/>Entity]
    VO1[Product ID<br/>Value Object]
    VO2[Quantity<br/>Value Object]

    AR -->|Contains| E1
    AR -->|Contains| E2
    E1 --> VO1
    E1 --> VO2

    style AR fill:#4caf50
    style E1 fill:#fff3e0
    style E2 fill:#fff3e0
    style VO1 fill:#e3f2fd
    style VO2 fill:#e3f2fd
public class Order // Aggregate Root
{
    private readonly OrderId _id;
    private readonly List<OrderLine> _lines; // Child entities

    public void AddLine(ProductId product, int quantity)
    {
        // Business rule: максимум 10 позицій
        if (_lines.Count >= 10)
        {
            throw new InvalidOperationException("Cannot exceed 10 order lines");
        }

        // Business rule: не більше 100 штук одного товару
        if (quantity > 100)
        {
            throw new InvalidOperationException("Max quantity = 100");
        }

        _lines.Add(new OrderLine(product, quantity));
    }
}

public class OrderLine // Entity (not aggregate!)
{
    private readonly OrderLineId _id;
    private readonly ProductId _product;
    private int _quantity;

    internal OrderLine(ProductId product, int quantity)
    {
        _id = OrderLineId.New();
        _product = product;
        _quantity = quantity;
    }
}
Aggregate RootЛише корінь ієрархії (Order) доступний ззовні. Child entities (OrderLine) доступні тільки через root.Це гарантує, що всі бізнес-правила перевіряються.

Concurrency Control (Керування конкурентним доступом)

Коли кілька користувачів одночасно працюють з одним aggregate, потрібен механізм розв'язання конфліктів.

Optimistic Locking

public class Ticket
{
    private Версії long _version; // Лічильник змін

    public void Escalate(Escalation Reason reason)
    {
        // Business logic...
        _version++; // Збільшуємо версію при кожній зміні
    }
}

Збереження в БД з перевіркою версії:

UPDATE Tickets
SET status = @status,
    priority = @priority,
    version = version + 1
WHERE id = @id
  AND version = @expectedVersion; -- ✅ Optimistic lock

-- Якщо version змінилась іншим процесом → 0 rows affected → Concurrency Exception

Aggregate vs Entity: Посібник з прийняття рішень

Найчастіше питання студентів

«Як визначити, чи це окремий aggregate, чи entity всередині іншого aggregate?»

Це критичне рішення, що впливає на:
  • Transaction boundaries
  • Concurrency ефективність
  • Масштабованість системи

Decision Flowchart

Loading diagram...
flowchart TD
    Start[Бізнес-об'єкт] --> Q1{Чи потрібен\nполе ID?}
    Q1 -->|Ні| VO[Value Object]
    Q1 -->|Так| Q2{Чи має власні\nбізнес-правила?}

    Q2 -->|Ні| Child[Entity\nвсередині aggregate]
    Q2 -->|Так| Q3{Чи потрібна\nконзистентність\nз іншими об'єктами?}

    Q3 -->|З батьківським об'єктом| Child
    Q3 -->|Незалежна| AggRoot[Aggregate Root]

    Q3 -->|Залежить від сценарію| Q4{Які кількісні\nспіврозмірності?}

    Q4 -->|1:Few| Child
    Q4 -->|1:Many або N:M| AggRoot

    style VO fill:#e3f2fd
    style Child fill:#fff3e0
    style AggRoot fill:#c8e6c9

Приклад 1: E-commerce Order


Приклад 2: система блогу


Приклад 3: Університетська система


Приклад 4: Banking System


Приклад 5: Project Management


Часті помилки при визначенні Aggregates

Mistake 1: Створення забагато aggregates
// ❌ Кожна дрібниця — aggregate
public class Order { /* aggregate */ }
public class OrderLine { /* aggregate */ } // ❌ Помилка!
public class Address { /* aggregate */ }   // ❌ Помилка!
Наслідки:
  • Погана продуктивність (N×queries)
  • Консистентність проблеми (атомарність порушується)
  • Складність координації
Mistake 2: Створення замало aggregates
// ❌ Один гігантський aggregate
public class Enterprise
{
    List<Department> _departments;
    List<Employee> _employees; // Тисячі!
    List<Project> _projects;   // Сотні!
    List<Task> _tasks;         // Десятки тисяч!
}
Наслідки:
  • Concurrency conflicts (частi lock conflicts)
  • Повільну завантаження
  • Складну логіку
Mistake 3:з Everything is Aggregate Root
// ❌ Навіть Value Objects стають aggregates
public class Color { /* aggregate root? */ } // ❌ Це Value Object!
public class PhoneNumber { /* aggregate root? */ } // ❌ Це Value Object!
Симптоми:
  • Aggregates без бізнес-правил
  • Aggregates без state changes
  • Тільки getters/setters

Матриця прийняття рішень

ПитанняEntity всерединіОкремий Aggregate
Чи має власні бізнес-правила?Ні або простіТак, складні
Чи має власний життєвий цикл?НіТак
Чи може існувати незалежно?НіТак
Який кількісний зв'язок?1:Few (<100)1:Many (>100) або N:M
Чи потрібна консистентність з батьківським?Так (atomic)Eventual OK
Чи потрібна concurrency performance?Ні (rare updates)Так (frequent updates)
Чи є точкою масштабування?НіТак

Практичний чеклист

Step 1: Визначте, чи потрібен ID

Ні → Value Object
Так → Продовжуємо

Step 2: Чи має власні важливі бізнес-правила?

Ні → Ймовірно Entity всередині
Так → Продовжуємо

Step 3: Чи може існувати без батьківського об'єкта?

Ні → Entity всередині
Так → Продовжуємо

Step 4: Яка кількість екземплярів?

1:Few (<50-100) → Можливо Entity всередині
1:Many (>100) або N:M → Окремий Aggregate

Step 5: Перевірте Transaction Boundary

Атомарні зміни обов'язкові? → Один Aggregate
Eventual consistency прийнятна? → Окремі Aggregates


Події предметної області (Domain Events)

КонцепціяDomain Events представляють зміни стану в lifecycle aggregate, що мають значення для бізнесу.
public class Ticket
{
    private List<IDomainEvent> _domainEvents = new();

    public void Escalate(EscalationReason reason)
    {
        // Business logic...
        _status = TicketStatus.Escalated;

        // ✅ Публікуємо подію
        _domainEvents.Add(new TicketEscalated(
            ticketId: _id,
            reason: reason,
            escalatedAt: DateTime.UtcNow
        ));
    }

    public IReadOnlyList<IDomainEvent> DomainEvents => _domainEvents;
}

// Event у минулому часі!
public class TicketEscalated : IDomainEvent
{
    public TicketId TicketId { get; }
    public EscalationReason Reason { get; }
    public DateTime EscalatedAt { get; }
}
Naming ConventionПодії завжди в минулому часі (past tense):
  • TicketEscalated
  • OrderSubmitted
  • PaymentConfirmed
  • EscalateTicket (це команда!)

Використання Domain Events

  1. Сповіщення інших систем
  2. Trigger асинхронних процесів
  3. Audit trail (Event Sourcing — Глава 7)
  4. Координація між aggregates

Доменні сервіси (Domain Services)

Іноді бізнес-логіка не належить жодному aggregate.

// Бізнес-логіка, що працює з КІЛЬКОМА aggregates
public class TransferMoneyService
{
    public void Transfer(Account from, Account to, decimal amount)
    {
        // Логіка не належить ні from, ні to
        if (amount <= 0)
        {
            throw new ArgumentException("Amount must be positive");
        }

        from.Withdraw(amount);
        to.Deposit(amount);

        // Domain Events
        from.AddEvent(new MoneyWithdrawn(from.Id, amount));
        to.AddEvent(new MoneyDeposited(to.Id, amount));
    }
}
Коли використовувати Domain Service?✅ Операції на кількох aggregates
✅ Stateless бізнес-логіка
✅ Логіка, що не вписується в жоден aggregate❌ Не використовуйте для уникнення розміщення logic в aggregates!

Висновок

У цій главі розглянули Domain Model pattern і його будівельні блоки:

Value Objects

Entities

Aggregates

Domain Events

Domain Services

Що далі?У наступній главі розглянемо, як моделювати фактор часу через паттерн Event Sourcing — де події стають джерелом истини, а не поточний стан.
Copyright © 2026