Ddd

Реалізація простої бізнес-логіки

Паттерни Transaction Script та Active Record для реалізації простої бізнес-логіки в Domain-Driven Design

Реалізація простої бізнес-логіки (Implementing Simple Business Logic)

Ключова ідея главиБізнес-логіка — найважливіша частина програмного забезпечення. Саме вона є першочерговою метою створення ПЗ. Незалежно від того, наскільки привабливий інтерфейс чи швидка база даних, якщо програма марна для бізнесу — це лише дорога демонстрація технологій.

Вступ

У Главі 2 ми дізналися, що не всі піддомени однакові. Різні піддомени мають різні рівні стратегічної важливості та складності. В цій главі розпочнемо вивчення різних способів моделювання та реалізації бізнес-логіки.

Чому важливо обирати правильний паттерн?Застосування складного паттерна для простої логіки призводить до непотрібної складності (accidental complexity). Навпаки, використання простого паттерна для складної логіки призводить до великого клубка бруду (big ball of mud) — неможливого для підтримки коду.

Що ми вивчимо?

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

Transaction Script

Active Record


Транзакційний сценарій (Transaction Script)

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

Martin Fowler, Patterns of Enterprise Application Architecture

Концепція паттерна

Loading diagram...
graph LR
    A[Публічний API] -->|Операція 1| B[Процедура 1]
    A -->|Операція 2| C[Процедура 2]
    A -->|Операція 3| D[Процедура 3]

    B --> E[(База даних)]
    C --> E
    D --> E

    style A fill:#e3f2fd
    style B fill:#c8e6c9
    style C fill:#c8e6c9
    style D fill:#c8e6c9
    style E fill:#fff3e0

Публічний інтерфейс системи можна розглядати як набір бізнес-транзакцій, доступних для виконання споживачами. Ці транзакції можуть:

  • 📖 Читати інформацію з системи
  • ✏️ Змінювати стан системи
  • 🔄 Виконувати обидві операції

Паттерн Transaction Script вибудовує бізнес-логіку на основі процедур, де:

✅ Кожна процедура реалізує одну операцію
✅ Публічні операції використовуються як межі інкапсуляції
✅ Процедури можуть безпосередньо звертатися до БД або через тонкий шар абстракції

Реалізація

Кожна процедура реалізована у вигляді простого процедурного сценарію. Єдина непохитна вимога — транзакційна поведінка.

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

Приклад: Конвертація JSON → XML

public class ConvertFileJob
{
    private IDatabase _db;

    public void Execute()
    {
        _db.StartTransaction();

        try
        {
            var job = _db.LoadNextJob();
            var json = LoadFile(job.Source);
            var xml = ConvertJsonToXml(json);

            WriteFile(job.Destination, xml.ToString());
            _db.MarkJobAsCompleted(job);

            _db.Commit();
        }
        catch
        {
            _db.Rollback();
            throw;
        }
    }
}

Це не так просто, як здається!

З досвіду викладання DDDКоли я представляю паттерн Transaction Script на заняттях з Domain-Driven Design, студенти часто здивовані: «Навіщо витрачати на це час? Хіба ми не для складніших моделей тут?»Справа в тому, що Transaction Script — основа для складніших паттернів. І незважаючи на уявну простоту, саме в ньому найлегше помилитися.

Безліч production-проблем, які я допомагав відлагоджувати, зводилися до неправильної реалізації транзакційної поведінки.

Три поширені помилки

Крок 1: Відсутність транзакційної поведінки

Найпростіша помилка — випуск кількох оновлень без транзакції, що їх охоплює.

Крок 2: Розподілені транзакції

Спроба атомарно оновити БД і опублікувати подію в шині повід омлень.

Крок 3: Неявні розподілені транзакції

Навіть однorядкове оновлення може бути розподіленою транзакцією!


Помилка №1: Відсутність транзакції

Розглянемо метод, що оновлює запис у таблиці Users та вставляє запис у VisitsLog:

public class LogVisit
{
    private IDatabase _db;

    public void Execute(Guid userId, DateTime visitedOn)
    {
        // ❌ Небезпечно! Дві операції без транзакції
        _db.Execute("UPDATE Users SET last_visit=@p1 WHERE user_id=@p2",
            visitedOn, userId);

        _db.Execute(@"INSERT INTO VisitsLog(user_id, visit_date)
            VALUES(@p1, @p2)", userId, visitedOn);
    }
}
Проблема: Race ConditionЯкщо після оновлення Users (рядок 8), але до успішного додавання в VisitsLog (рядок 11) станеться будь-яка проблема — система опиниться в неузгодженому стані:
  • ✅ Таблиця Users оновлена
  • ❌ Запис у VisitsLog відсутній
Причини збою: мережевий збій, timeout БД, deadlock, збій сервера, тощо.

Рішення: Додавання транзакції

public class LogVisit
{
    private IDatabase _db;

    public void Execute(Guid userId, DateTime visitedOn)
    {
        try
        {
            _db.StartTransaction(); // ✅ Початок транзакції

            _db.Execute("UPDATE Users SET last_visit=@p1 WHERE user_id=@p2",
                visitedOn, userId);

            _db.Execute(@"INSERT INTO VisitsLog(user_id, visit_date)
                VALUES(@p1, @p2)", userId, visitedOn);

            _db.Commit(); // ✅ Фіксація змін
        }
        catch
        {
            _db.Rollback(); // ✅ Відкат при помилці
            throw;
        }
    }
}
Переваги рішенняРеляційні БД мають вбудовану підтримку транзакцій, що дозволяє атомарно виконувати кілька операцій. Це легко виправити.

Але що робити, коли потрібно оновити кілька сховищ даних, які не підтримують розподілені транзакції?


Помилка №2: Розподілені транзакції

У сучасних розподілених системах звичайна практика — внести зміни в БД, а потім опублікувати події в шину повідомлень для оповіщення інших компонентів.

Припустімо, замість логування візиту в таблицю, нам потрібно опублікувати подію:

public class LogVisit
{
    private IDatabase _db;
    private IMessageBus _messageBus;

    public void Execute(Guid userId, DateTime visitedOn)
    {
        // ❌ Проблема: дві різні системи без загальної транзакції
        _db.Execute("UPDATE Users SET last_visit=@p1 WHERE user_id=@p2",
            visitedOn, userId);

        _messageBus.Publish("VISITS_TOPIC",
            new { UserId = userId, VisitDate = visitedOn });
    }
}
Проблема: Dual WriteЯк і в попередньому прикладі, будь-який збій після виконання рядка 8, але до успішного виконання рядка 11, призведе до пошкодження стану:
  • ✅ Таблиця Users оновлена
  • ❌ Інші компоненти не оповіщені (публікація дала збій)
НЕ існує простого рішення! Розподілені транзакції, що охоплюють кілька сховищ даних:
  • 🐌 Складні
  • 📉 Важко масштабуються
  • ❌ Не стійкі до помилок

Помилка №3: Неявні розподілені транзакції

Розглянемо метод, що виглядає обманливо простим:

public class LogVisit
{
    private IDatabase _db;

    public void Execute(Guid userId)
    {
        // Здається просто? Насправді це розподілена транзакція!
        _db.Execute("UPDATE Users SET visits=visits+1 WHERE user_id=@p1",
            userId);
    }
}
Питання для роздумівЦей метод оновлює лише одне значення в одній таблиці в одній базі даних. Чому це все ж таки розподілена транзакція?
Loading diagram...
sequenceDiagram
    participant C as Клієнт (Caller)
    participant M as Метод LogVisit
    participant DB as База даних

    C->>M: Execute(userId)
    activate M
    M->>DB: UPDATE visits+1
    activate DB
    DB-->>M: Success ✓
    deactivate DB
    M-->>C: Return (Success)
    deactivate M

    Note over C,DB: Що якщо зв'язок обривається<br/>ПІСЛЯ успіху, але ДО повернення?

Проблема: Втрачений ack (acknowledgment)

Цей приклад є розподіленою транзакцією, оскільки передає інформацію в дві системи:

  1. 💾 База даних
  2. 📞 Зовнішній процес (викликач методу)
Сценарії збоюМетод Execute має тип void — не повертає даних. Але він все одно повідомляє результат: у разі невдачі викликач отримає виключення.Що якщо метод завершився успішно, але повернення результату дало збій?
  • REST-сервіс: Збій мережі між сервером і клієнтом
  • Локальний процес: Процес завершився зі збоєм до того, як викликач встиг відстежити успіх
В обох випадках споживач припускає помилку і намагається викликати LogVisit знову. Повторне виконання призведе до некоректного збільшення лічильника: +2 замість +1.

Рішення №1: Ідемпотентність через передачу значення

Зробимо операцію ідемпотентною — приводити до однакового результату навіть при багаторазовому повторенні:

public class LogVisit
{
    private IDatabase _db;

    // ✅ Споживач передає очікуване значення
    public void Execute(Guid userId, long visits)
    {
        // MEETING навіть при повторних викликах буде встановлено те саме значення
        _db.Execute("UPDATE Users SET visits = @p1 WHERE user_id=@p2",
            visits, userId);
    }
}
Чому це працює?
  1. Викликач спочатку зчитує поточне значення лічильника
  2. Локально збільшує його
  3. Передає оновлене значення як параметр
Навіть якщо операція виконається кілька разів, кінцевий результат не зміниться!

Рішення №2: Оптимістична блокування

Перед викликом LogVisit викликач зчитує поточне значення і передає його для перевірки:

public class LogVisit
{
    private IDatabase _db;

    // ✅ Використовуємо expected value для оптимістичної блокування
    public void Execute(Guid userId, long expectedVisits)
    {
        // Оновлення відбудеться ТІЛЬКИ якщо значення не змінилось
        var rowsAffected = _db.Execute(
            @"UPDATE Users
              SET visits = visits + 1
              WHERE user_id=@p1 AND visits = @p2",
            userId, expectedVisits);

        if (rowsAffected == 0)
        {
            throw new ConcurrencyException("Visits count has changed");
        }
    }
}

Коли застосовувати Transaction Script?

Підходить для

НЕ підходить для

Переваги паттерна

АспектОпис
ПростотаМінімальна кількість абстракцій
ПродуктивністьМінімальні накладні витрати
ЗрозумілістьЛегко розібратися в реалізації
ПрямолінійністьПрев зв'язок між операцією та кодом

Недоліки паттерна

Обмеження Transaction ScriptДублювання логіки — чим складніша бізнес-логіка, тим більше повторюється код між процедурами❌ Розсинхронізація — продубльований код легко призводить до різної поведінки❌ Big Ball of Mud — складна логіка в Transaction Script перетворюється на непідтримуваний клубок❌ Масштабованість — важко додавати нову функціональність без порушення існуючої
Репутація паттернаПростота Transaction Script створила йому сумнівну репутацію. Іноді його навіть розглядають як антипаттерн.Але потрібно відзначити: незважаючи на недоліки, цей паттерн отримав у розробці ПЗ дуже широке поширення. Всі паттерни реалізації бізнес-логіки, що розглядатимуться далі, так чи інакше базуються на Transaction Script.

Активна запис (Active Record)

Об'єкт, що представляє рядок у таблиці або представленні бази даних, інкапсулює доступ до бази даних і бізнес-логіку, що оперує цими даними.

Martin Fowler, Patterns of Enterprise Application Architecture

Відмінності від Transaction Script

Active Record, як і Transaction Script, придатний для простої бізнес-логіки. Але відмінність:

Loading diagram...
graph TB
    subgraph "Transaction Script"
        TS1[Процедура 1] --> DB1[(БД)]
        TS2[Процедура 2] --> DB1
        TS3[Процедура 3] --> DB1
    end

    subgraph "Active Record"
        AR1[Active Record 1] <--> DB2[(БД)]
        AR2[Active Record 2] <--> DB2
        AR3[Active Record 3] <--> DB2

        AR1 -.Contains.-> Data1[Data + CRUD]
        AR2 -.Contains.-> Data2[Data + CRUD]
        AR3 -.Contains.-> Data3[Data + CRUD]
    end

    style TS1 fill:#ffcdd2
    style TS2 fill:#ffcdd2
    style TS3 fill:#ffcdd2
    style AR1 fill:#c8e6c9
    style AR2 fill:#c8e6c9
    style AR3 fill:#c8e6c9

Transaction Script працює з простими даними та процедурами.
Active Record може працювати зі складними структурами даних — деревами об'єктів та ієрархіями.

Проблема: Дублювання коду

Розглянемо складнішу модель даних:

Loading diagram...
erDiagram
    USER ||--o{ ORDER : places
    ORDER ||--|{ ORDER_LINE : contains
    PRODUCT ||--o{ ORDER_LINE : "ordered in"
    PRODUCT }|--|{ CATEGORY : "belongs to"

    USER {
        uuid id PK
        string name
        string email
    }
    ORDER {
        uuid id PK
        uuid user_id FK
        datetime created_at
    }
    ORDER_LINE {
        uuid id PK
        uuid order_id FK
        uuid product_id FK
        int quantity
    }
    PRODUCT {
        uuid id PK
        string name
        decimal price
    }
    CATEGORY {
        uuid id PK
        string name
    }
Робота з такими структурами даних через Transaction Script призвела б до великого обсягу повторюваного коду: зіставлення даних з їх представленням у пам'яті дублювалося б скрізь.

Рішення: Active Record Objects

Паттерн використовує спеціальні об'єкти — Active Records — для представлення складних структур даних:

// ✅ Active Record інкапсулює і дані, і доступ до БД
public class User
{
    public Guid Id { get; set; }
    public string Name { get; set; }
    public string Email { get; set; }

    // CRUD-методи вбудовані в об'єкт
    public void Save() { /* ORM logic */ }
    public static User Load(Guid id) { /* ORM logic */ }
    public void Delete() { /* ORM logic */ }
}

public class Order
{
    public Guid Id { get; set; }
    public Guid UserId { get; set; }
    public List<OrderLine> Lines { get; set; }

    public void Save() { /* ORM logic */ }
    public static Order Load(Guid id) { /* ORM logic */ }
}
Зв'язок з ORMОб'єкти Active Record зазвичай пов'язані з ORM-фреймворками (Entity Framework, Hibernate, ActiveRecord) або іншими інструментами доступу до даних.Назва паттерна походить від того факту, що кожна структура даних є «активною» — реалізує логіку доступу до даних.

Використання в Transaction Script

Бізнес-логіка все ще організована як транзакційний сценарій, але замість прямого доступу до БД маніпулює об'єктами Active Record:

public class CreateUser
{
    private IDatabase _db;

    public void Execute(UserDetails userDetails)
    {
        try
        {
            _db.StartTransaction();

            // ✅ Працюємо через Active Record, а не з SQL напряму
            var user = new User();
            user.Name = userDetails.Name;
            user.Email = userDetails.Email;
            user.Save(); // Active Record сам знає як зберегтись

            _db.Commit();
        }
        catch
        {
            _db.Rollback();
            throw;
        }
    }
}

Коли застосовувати Active Record?

По суті, Active Record — це Transaction Script optimization для роботи з БД:

Підходить для

НЕ підходить для

Порівняння з Transaction Script

АспектTransaction ScriptActive Record
Бізнес-логікаПроста процедурнаПроста CRUD-орієнтована
Структури данихПрості записиСкладні ієрархії об'єктів
Доступ до БДПрямий або тонка абстракціяЧерез ORM/Active Record
Підходить дляETL, простi операціїCRUD зі складними даними
Дублювання кодуВисока ймовірністьЗменшено через ORM

«Анемічна модель» чи ні?

Термінологія та стигмаActive Record часто називають антипаттерном «анемічна модель предметної області» (Anemic Domain Model) або неправильно спроектованою доменною моделлю.Я віддаю перевагу утримуватись від негативного підтексту слів «анемічний» та «антипаттерн».
Правильна перспективаЦей паттерн є інструментом. Як і будь-який інший інструмент:✅ Може вирішувати поставлені перед ним задачі
❌ Може завдати шкоди, якщо застосувати в неправильному місціЯкщо бізнес-логіка не відрізняється особливою складністю, у використанні Active Records немає нічого поганого.Але використання більш складного паттерна для простої логіки також завдасть шкоди — призведе до непотрібної складності.

В наступній главі розкриємо паттерн Domain Model і покажемо, чим він відрізняється від Active Record.

Важливе уточненняУ цьому контексті Active Record означає паттерн проектування, а не фреймворк Active Record.Назву паттерна придумав Martin Fowler у книзі «Patterns of Enterprise Application Architecture». Фреймворк з'явився пізніше як один із способів реалізації цього паттерна.Тут йдеться про паттерн проектування та концепції, що лежать в його основі, а не про конкретну реализацію.

Прагматичний підхід

Бізнес vs ІдеальністьХоча бізнес-дані важливі, і код повинен забезпечувати їх цілісність, у деяких випадках краще дотримуватись прагматичного підходу.

Коли можна послабити гарантії?

Бувають випадки, коли гарантії узгодженості даних можуть бути послаблені, особливо при високих вимогах масштабованості:

Золоте правило прагматизмуУніверсальних законів просто не існує. Все залежить від:
  1. Області бізнесу — фінанси vs аналітика vs соціальні мережі
  2. Оцінки ризиків — що станеться при втраті/дублюванні даних?
  3. Бізнес-наслідків — як це вплине на прибутковість?
Можна «зрізати кути» скрізь, де це можливо, просто потрібно переконатись, що ризикам і наслідкам для бізнесу дана розумна оценка.

Висновок

У цій главі наші розглянули два паттерни для реалізації бізнес-логіки:

Transaction Script

Active Record

Що далі?Розглянуті паттерни орієнтовані на випадки застосування порівняно простої бізнес-логіки.У наступній главі розглянемо більш складну бізнес-логіку і запропонуємо способи подолання додаткових складнощів шляхом застосування паттерна «Модель предметної області» (Domain Model).

Практичні вправи

::

Подивіться на наступний код:

public void CreateTicket(TicketData data)
{
    var agent = FindLeastBusyAgent();

    agent.ActiveTickets = agent.ActiveTickets + 1;
    agent.Save();

    var ticket = new Ticket();
    ticket.Id = Guid.NewGuid();
    ticket.Data = data;
    ticket.AssignedAgent = agent;
    ticket.Save();

    _alerts.Send(agent, "You have a new ticket!");
}

Питання: Якщо припустити відсутність високорівневого механізму транзакцій, які потенційні проблеми з узгодженістю даних можуть виникнути?

А) Лічильник активних запитів агента може бути збільшений більше ніж на 1
Б) Лічильник може збільшитись, але агенту не буде присвоєно новий запит
В) Агент може отримати новий запит, але не буде про це оповіщений
Г) Можливе виникнення всіх вищеперелічених проблем

Знайдіть ще одну потенційну проблему в коді з Вправи 2, що може внести розлад у стан системи.

::

Copyright © 2026