DDD

Глава 8. Архітектурні Патерни

Глибоке занурення в архітектурні стилі: від Шаруватої Архітектури до Портів та Адаптерів і CQRS. Вибір правильної структури для вашої бізнес-логіки.

Глава 8. Архітектурні Патерни

"Архітектура — це рішення, які важко змінити пізніше. Тому варто приділити їм увагу зараз." — Мартін Фаулер

У попередніх главах ми фокусувалися на тактичних патернах (Tactical Patterns) — будівельних блоках, які допомагають нам моделювати бізнес-логіку всередині одного Обмеженого Контексту (Bounded Context). Ми говорили про Агрегати, Сутності, Об'єкти-Значення та Доменні Сервіси. Це наші "цеглинки" та "розчин".

Але як нам скласти ці цеглинки в стійку будівлю? Як організувати взаємодію між бізнес-логікою, базою даних, користувацьким інтерфейсом та зовнішніми API так, щоб система не перетворилася на "Велику Грудку Бруду" (Big Ball of Mud)?

У цій главі ми піднімемося на рівень вище і розглянемо архітектурні патерни. Це стратегії організації кодової бази, які визначають, як різні частини системи спілкуються одна з одною.

1. Вступ та Контекст

Проблема: Хаос залежностей

Уявіть, що ви пишете простий скрипт для автоматизації. Ви змішуєте логіку (як обробити дані), введення (звідки взяти дані) і виведення (куди покласти результат) в одному файлі. Це працює для 100 рядків коду.

Тепер уявіть Enterprise-систему на 500,000 рядків коду. Якщо бізнес-логіка напряму викликає базу даних, а UI напряму звертається до бізнес-правил, які, в свою чергу, малюють HTML — ви отримуєте систему, яку неможливо протестувати, неможливо змінити і страшно розгортати.

Жорстка Зв'язність (High Coupling)

Зміна в схемі бази даних ламає UI. Оновлення бібліотеки логування вимагає перекомпіляції бізнес-ядра.

Низька Зв'язність (Low Cohesion)

Код, що відповідає за розрахунок податків, розмазаний по контролерах, SQL-процедурах та JavaScript-файлах.

Мета: Організований Хаос

Архітектурні патерни дають нам:

  1. Структуру: Чіткі місця для кожного типу коду.
  2. Розділення Відповідальності (SoC): Кожен модуль робить одну річ і робить її добре.
  3. Тестованість: Можливість перевірити бізнес-логіку без підняття бази даних чи веб-сервера.

Ми розглянемо три фундаментальні підходи, кожен з яких еволюціонував з попереднього:

Шарувата Архітектура (Layered Architecture)

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

Порти та Адаптери (Ports and Adapters)

Також відома як Гексагональна або Чиста Архітектура. Інвертує залежності, ставлячи домен у центр всесвіту.

CQRS (Command-Query Responsibility Segregation)

Розділення системи на дві частини: одну для зміни стану (Write), іншу для читання (Read). Вища ліга для складних доменів.


2. Співставлення Бізнес-Логіки та Архітектури

Бізнес-логіка — це серце вашого програмного забезпечення, але це не єдиний орган. Системі потрібні "руки" (UI), "пам'ять" (DB) та "вуха" (API).

Різноманітність завдань створює спокусу розподілити бізнес-логіку де попало:

  • Трохи валідації в JavaScript на фронтенді.
  • Трохи розрахунків у контролері.
  • Трохи логіки в збережених процедурах SQL.

Це шлях до пекла підтримки (Maintenance Hell).

Архітектурні патерни вводять правила гри. Вони кажуть: "Ти, UI, можеш говорити тільки з Сервісом. А ти, Сервіс, нічого не знаєш про Базу Даних напряму".

Вибір архітектурного патерну — це не просто вибір структури папок. Це вибір спрямування залежностей (Direction of Dependencies). Саме те, хто від кого залежить, визначає гнучкість вашої системи.

3. Шарувата Архітектура (Layered Architecture)

Це найстаріший, найвідоміший і досі найпопулярніший патерн. Якщо ви коли-небудь створювали папку Models, Views та Controllers (MVC) — ви вже частково знайомі з ідеєю шарів, хоча N-Tier архітектура йде трохи далі.

Концепція

Уявіть геологічні шари землі або, що смачніше, торт "Наполеон". Кожен шар лежить на іншому. Верхній шар спирається на нижній, але нижній шар нічого не знає про верхній.

У класичній формі ми маємо три основні шари (Layers):

  1. Шар Представлення (Presentation Layer - PL)
  2. Шар Бізнес-Логіки (Business Logic Layer - BLL)
  3. Шар Доступу до Даних (Data Access Layer - DAL)
Loading diagram...

graph TD PLPresentation Layer
(UI, API, CLI)
BLLBusiness Logic Layer
(Domain Models, Services)
DALData Access Layer
(Repositories, Database)

PL -->|Використовує| BLL
BLL -->|Використовує| DAL

style PL fill:#e1f5fe,stroke:#01579b
style BLL fill:#fff9c4,stroke:#fbc02d
style DAL fill:#e8f5e9,stroke:#2e7d32

Анатомія Шарів

1. Шар Представлення (Presentation Layer)

Це "обличчя" вашої системи. Тут відбувається взаємодія з зовнішнім світом.

Що тут живе:

  • Веб-контролери (MVC Controllers).
  • REST API ендпоінти.
  • gRPC сервіси.
  • CLI команди.
  • Consumer-и черг повідомлень (Kafka, RabbitMQ).

Відповідальність:

  • Прийняти запит (HTTP, Command, Message).
  • Провалідувати формат вхідних даних (але не бізнес-правила!).
  • Перетворити дані у формат, зрозумілий бізнес-логіці (DTO -> Domain Model).
  • Викликати бізнес-логіку.
  • Перетворити результат назад у формат відповіді (Domain Model -> View Model / JSON).
  • Обробити помилки та повернути відповідний статус (404, 500).
Anti-Pattern: Smart ControllerКонтролер не повинен містити жодної бізнес-логіки. Ніяких if (user.balance < price), ніяких розрахунків знижок. Його робота — бути перекладачем і диспетчером.

2. Шар Бізнес-Логіки (Business Logic Layer)

Це "мозок" системи. Тут приймаються рішення.

Що тут живе:

  • Сутності (Entities).
  • Об'єкти-Значення (Value Objects).
  • Доменні Сервіси (Domain Services).
  • Сценарії Транзакцій (Transaction Scripts).

Відповідальність:

  • Реалізація бізнес-правил та інваріантів.
  • Виконання обчислень.
  • Координація змін стану.

Як зазначає Ерік Еванс, цей шар є серцем програмного забезпечення. Саме тут реалізуються патерни, які ми розглядали в главах 5-7 (Active Record або Domain Model).

3. Шар Доступу до Даних (Data Access Layer)

Це "сховище" системи.

Що тут живе:

  • Репозиторії (Repositories).
  • Data Access Objects (DAO).
  • ORM Mapping конфігурації (EntityFramework Core, Hibernate).
  • Клієнти до зовнішніх API (якщо вони розглядаються як джерело даних).

Відповідальність:

  • CRUD операції (Create, Read, Update, Delete).
  • Побудова SQL запитів.
  • Мапінг результатів з бази даних у об'єкти в пам'яті.

!NOTE У сучасних реаліях DAL — це не тільки SQL база даних. Це може бути NoSQL (MongoDB), пошукового індексу (Elasticsearch), кешу (Redis) або навіть хмарного сховища файлів (S3). Для шару бізнес-логіки деталі реалізації DAL мають бути сховані за абстракцією (хоча в класичній Layed Architecture це часто порушується).


Правила Комунікації

Головне правило Шаруватої Архітектури: Залежності спрямовані зверху вниз.

  1. Шар Представлення знає про Шар Бізнес-Логіки.
  2. Шар Бізнес-Логіки знає про Шар Доступу до Даних.
  3. Шар Доступу до Даних не знає нікого вище себе.

Це забезпечує певну ізоляцію. Якщо ви зміните верстку HTML у PL, це не вплине на BLL. Якщо ви оптимізуєте SQL запит у DAL, BLL і PL про це навіть не дізнаються (теоретично).

Сувора vs. Послаблена Шаруватість (Strict vs. Relaxed Layers)

  • Сувора (Strict): Шар може звертатися тільки до шару, що безпосередньо під ним. PL -> BLL -> DAL. PL не може викликати DAL напряму.
  • Послаблена (Relaxed): Шар може звертатися до будь-якого шару, що знаходиться нижче. PL може викликати DAL для простих запитів на читання (наприклад, для заповнення dropdown-списку).
Для систем з багатою логікою краще дотримуватися Суворої Шаруватості, щоб уникнути розтікання логіки доступу до даних по всьому додатку.

Варіація: Сервісний Шар (Service Layer)

Часто трьох шарів недостатньо. Коли бізнес-логіка складна, контролери стають занадто товстими, або бізнес-сутності починають займатися оркестрацією. Тут на сцену виходить Сервісний Шар.

Це додатковий прошарок між PL та BLL.

"Сервісний шар визначає межу додатка і набір доступних операцій з точки зору клієнтів інтерфейсу." — Мартін Фаулер

Loading diagram...

graph TD PLPresentation Layer SLService Layer
(Application Logic)
BLLBusiness Logic Layer
(Domain Model)
DALData Access Layer

PL --> SL
SL --> BLL
SL --> DAL
BLL --> DAL

style SL fill:#ffecb3,stroke:#ffa000

Роль Сервісного Шару

Він виступає як Оркестратор. Він не містить "чистої" бізнес-логіки (правил розрахунку цін, валідації стану сутності), він містить логіку сценаріїв використання (Use Case Logic).

Типовий код методу в Service Layer:

  1. Відкрити транзакцію.
  2. Завантажити Агрегат з Репозиторію (через DAL).
  3. Викликати метод бізнес-логіки в Агрегаті (зробити дію).
  4. Зберегти зміни через Репозиторій.
  5. Закомітити транзакцію.
  6. (Опціонально) Відправити email або подію.

Приклад Рефакторингу: Від "Товстого Контролера" до Сервісу

Розглянемо приклад створення користувача.

Варіант 1: Логіка в Контролері (Погано)
// MvcApplication.Controllers.UserController
public class UserController : Controller
{
    private readonly DbContext _db;

    [HttpPost]
    public ActionResult Create(ContactDetails contactDetails)
    {
        // 1. Початок інфраструктурної роботи
        using var transaction = _db.Database.BeginTransaction();

        try
        {
            // 2. Створення сутності (Бізнес-логіка)
            var user = new User();
            user.SetContactDetails(contactDetails);

            // 3. Збереження (DAL)
            _db.Users.Add(user);
            _db.SaveChanges();

            transaction.Commit();
            return View("Success");
        }
        catch (Exception ex)
        {
            transaction.Rollback();
            return View("Error", ex);
        }
    }
}

Проблеми:

  • Контролер знає про транзакції БД.
  • Контролер прив'язаний до Entity Framework (DbContext).
  • Цю логіку неможливо перевикористати (наприклад, з CLI утиліти або фонового воркера).
Варіант 2: Виділення Сервісного Шару (Краще)

Ми створюємо UserService, який інкапсулює "Сценарій Створення Користувача".

// ServiceLayer.UserService
public class UserService
{
    private readonly IUserRepository _repository;
    private readonly ITransactionManager _transactionManager;

    public OperationResult Create(ContactDetails contactDetails)
    {
        try
        {
            _transactionManager.Begin();

            var user = new User();
            user.SetContactDetails(contactDetails);

            _repository.Add(user);

            _transactionManager.Commit();
            return OperationResult.Success();
        }
        catch (Exception ex)
        {
            _transactionManager.Rollback();
            return OperationResult.Failure(ex);
        }
    }
}

// MvcApplication.Controllers.UserController
public class UserController : Controller
{
    public ActionResult Create(ContactDetails contactDetails)
    {
        // Контролер просто делегує виконання
        // Вся "брудна" робота схована в сервісі
        var result = _userService.Create(contactDetails);

        return result.IsSuccess ? View("Success") : View("Error", result.Error);
    }
}

Переваги:

  1. Reusability: UserService можна викликати з MVC, Web API, gRPC, Console App.
  2. Separation: Контролер чистий, він займається лише HTTP/View питаннями.
  3. Testing: UserService легко протестувати юніт-тестами, замокавши репозиторій.

Коли використовувати Шарувату Архітектуру?

Це чудовий старт.

За:

  • Простота розуміння командою.
  • Стандарт де-факто для багатьох фреймворків (Rails, Django, Spring MVC, ASP.NET MVC).
  • Добре підходить для патернів Transaction Script та Active Record.

Проти:

  • Database Driven Development: Оскільки BLL залежить від DAL, ми часто починаємо проектування з таблиць у базі даних, а не з поведінки об'єктів.
  • Труднощі з Domain Model: Якщо ви хочете справжню чисту Доменну Модель, яка не залежить від інфраструктури, шарувата архітектура ставить палки в колеса, бо змушує бізнес-шар залежати від шару даних.
  • Транзитивні залежності: Зміни в DAL можуть каскадом ламати BLL і PL.

Термінологічна плутанина

У різних джерелах шари називаються по-різному. Ось "словник перекладу":

Ваше джерелоЦя книга (DDD)Альтернативи
PresentationPresentation LayerUI Layer, Web Layer, Interface Layer
LogicBusiness Logic LayerDomain Layer, Model Layer, Core
ApplicationService LayerApplication Layer, Use Case Layer
DataData Access LayerInfrastructure Layer, Persistence Layer
Шари vs. Рівні (Layers vs. Tiers)Це не одне й те саме!
  • Layers (Шари): Логічний поділ коду. Вони можуть жити в одній DLL або одному моноліті.
  • Tiers (Рівні): Фізичний поділ. Наприклад, Web Server (Tier 1) -> App Server (Tier 2) -> Database Server (Tier 3). Це різні машини/процеси.

4. Порти та Адаптери (Ports and Adapters)

Класична шарувата архітектура має фатальний недолік для складних доменів: залежність бізнесу від даних. Уявіть, що ваша бізнес-логіка (найцінніше, що у вас є) залежить від бібліотеки доступу до бази даних (найбільш мінливої і технічної частини). Якщо ви хочете замінити SQL на NoSQL, вам доведеться переписувати бізнес-логіку або принаймні перекомпілювати її. Це неправильно.

Принцип Інверсії Залежностей (Dependency Inversion Principle - DIP) каже:

"Високорівневі модулі не повинні залежати від низькорівневих. Обидва повинні залежати від абстракцій."

Архітектура Порти та Адаптери (також відома як Гексагональна Архітектура, Цибулева Архітектура, Чиста Архітектура) вирішує цю проблему радикально.

Зміна Парадигми

Ми перестаємо думати про систему як про "пиріг" шарів (зверху вниз). Ми думаємо про неї як про Ядро (Core), яке знаходиться в центрі, та Зовнішній Світ (Infrastructure), який його оточує.

Концепція

Уявіть ігрову консоль.

  • Ядро (Консоль): Це бізнес-логіка ігор. Вона не знає, який телевізор ви підключите (Sony, Samsung) і який джойстик використаєте (оригінальний, китайський).
  • Порти: Гнізда на консолі (HDMI, USB). Це контракти (інтерфейси), які визначає консоль. "Якщо хочеш отримати картинку, встав кабель, який підходить у це гніздо".
  • Адаптери: Самі кабелі та драйвери. Вони адаптують сигнали зовнішніх пристроїв до портів консолі.

У цій архітектурі Бізнес-Логіка стає центром всесвіту. Вона не залежить ні від чого. Вона визначає правила гри.

Loading diagram...

graph TD %% Core Domain subgraph Core Domaine Core DomainEntitiesEntities & Value Objects UseCasesApplication Services PortsPorts
(Interfaces)
end

%% Infrastructure / Adapters
subgraph Infrastructure [Adapters & Infrastructure]
    Web[Web Controller]
    CLI[CLI Console]
    DB[Database Adapter]
    Email[Email Adapter]
end

%% Dependencies
Web -->|Calls| Ports
CLI -->|Calls| Ports
DB -.->|Implements| Ports
Email -.->|Implements| Ports

style Core fill:#fff9c4,stroke:#fbc02d,stroke-width:2px
style Infrastructure fill:#e1f5fe,stroke:#01579b,stroke-dasharray: 5 5

Зверніть увагу на стрілки на діаграмі. Всі залежності спрямовані всередину, до Ядра. Інфраструктура залежить від Домену. Домен не залежить від Інфраструктури.

Реалізація DIP (Dependency Inversion)

Як це працює на рівні коду? Давайте порівняємо.

У Шаруватій архітектурі (неправильно для DDD)

namespace App.BusinessLogic
{
    // Бізнес-логіка залежить від БД
    using App.DataAccess;

    public class OrderService
    {
        // ПРЯМА залежність від класу з DAL!
        private SqlRepository _repo = new SqlRepository();

        public void PlaceOrder(Order order)
        {
            if (!order.IsValid()) throw new Exception();
            _repo.Save(order);
        }
    }
}

У Портах та Адаптерах (правильно для DDD)

1. Порт (Інтерфейс) в Ядрі: Спочатку бізнес-логіка каже: "Мені потрібно вміти зберігати замовлення. Я не знаю як, і мені байдуже куди (файл, БД, хмара). Ось контракт:"

namespace App.Core.Ports
{
    public interface IOrderRepository
    {
        void Save(Order order);
    }
}

2. Бізнес-логіка використовує Порт:

namespace App.Core.Services
{
    using App.Core.Ports;

    public class OrderService
    {
        private readonly IOrderRepository _repo; // Залежність від ІНТЕРФЕЙСУ

        // Ми отримуємо реалізацію через конструктор (Dependency Injection)
        public OrderService(IOrderRepository repo)
        {
            _repo = repo;
        }

        public void PlaceOrder(Order order)
        {
            if (!order.IsValid()) throw new Exception();
            _repo.Save(order);
        }
    }
}

3. Адаптер (Реалізація) в Інфраструктурі: Тільки тепер, в окремому проекті або папці, ми створюємо клас, який знає про базу даних.

namespace App.Infrastructure.Adapters
{
    using App.Core.Ports;
    using Microsoft.EntityFrameworkCore;

    // Адаптер реалізує Порт
    public class SqlOrderRepository : IOrderRepository
    {
        private readonly DbContext _db;

        public SqlOrderRepository(DbContext db) => _db = db;

        public void Save(Order order)
        {
            // Перетворюємо Доменну модель в SQL запит
            var record = MapToSqlRecord(order);
            _db.Orders.Add(record);
            _db.SaveChanges();
        }
    }
}

Інтеграція Інфраструктурних Компонентів

Поняття "Порти та Адаптери" покриває два напрямки комунікації:

1. Первинні (Driving) Адаптери

Це те, що викликає вашу програму. Вони "керують" вашим додатком.

  • Хто: Користувач через UI, тестовий фреймворк, зовнішній Webhook, CRON-job.
  • Як: Вони викликають методи Прикладного Шару (Application Service) через вхідні Порти.
  • Приклад: MVC Controller адаптує HTTP запит у виклик методу OrderService.PlaceOrder().

2. Вторинні (Driven) Адаптери

Це те, що викликається вашою програмою. Вони "керовані" вашим додатком.

  • Хто: База даних, SMTP сервер, Шина повідомлень, файлова система.
  • Як: Ваше Ядро викликає інтерфейс (Вихідний Порт), а Адаптер реалізує цей інтерфейс.
  • Приклад: SqlOrderRepository реалізує IOrderRepository. SmtpEmailSender реалізує IEmailSender.

Варіації: Гексагональна, Цибулева, Чиста

Давайте розберемося в цьому зоопарку назв. Вони описують одну й ту саму суть, але з різними метафорами.

Гексагональна Архітектура (Alistair Cockburn)

Оригінальна назва. "Гексагон" (шестикутник) тут просто для того, щоб показати, що у програми може бути багато "сторін" (портів) підключення, а не тільки "верх" і "низ". Акцент на симетрії інтерфейсів та тестуванні.

Цибулева Архітектура (Jeffrey Palermo)

Розвиває ідею кілець.

  • Центр: Domain Model (лише об'єкти і поведінка).
  • Кільце 2: Domain Services (складна логіка).
  • Кільце 3: Application Services (оркестрація).
  • Зовнішні кільця: UI, Tests, Infrastructure. Головне правило: усі залежності спрямовані до центру.

Чиста Архітектура (Uncle Bob)

Найбільш відома версія. Об'єднує ідеї попередніх.

  • Entities: Enterprise Business Rules.
  • Use Cases: Application Business Rules.
  • Interface Adapters: Controllers, Gateways, Presenters.
  • Frameworks & Drivers: DB, Web, UI.

Суть у них одна й та сама: Бізнес-логіка ізольована від деталей реалізації.

Коли використовувати Порти та Адаптери?

Цей патерн ідеальний для систем, де ви використовуєте Domain Driven Design.

Переваги:

  • Тестованість: Ви можете протестувати все ядро, не маючи бази даних. Просто створіть InMemoryOrderRepository або Mock. Ваші тести будуть літати (мілісекунди замість секунд).
  • Гнучкість: Хочете замінити Oracle на PostgreSQL? Пишете новий адаптер. Хочете додати керування через Telegram-бота? Пишете новий вхідний адаптер. Ядро не змінюється.
  • Відкладені рішення: Ви можете розробляти ядро системи, навіть не вирішивши, яку базу даних будете використовувати. Ви можете відкласти це рішення на місяці.

Недоліки:

  • Складність: Багато нових файлів (інтерфейси, DTO, мапери). Для простого "Hello World" або CRUD це оверхед (Over-engineering).
  • Крива навчання: Розробникам потрібно звикнути до інверсії залежностей і розділення моделей.
  • Debugging: Іноді важко прослідкувати потік виконання, бо ви кликаєте "Go To Implementation" і потрапляєте на інтерфейс, а не на код.

5. CQRS (Command-Query Responsibility Segregation)

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

Фундаментальна Проблема

У звичайних системах ми використовуємо одну й ту саму модель (клас) і для читання, і для запису.

  • Ми маємо клас User.
  • Коли ми реєструємо користувача (Запис), нам потрібні поля PasswordHash, Salt, RegistrationDate, IsActive.
  • Коли ми показуємо список користувачів в адмінці (Читання), нам потрібні FullName, LastLogin, але точно не потрібен PasswordHash.

З часом наша модель User стає монстром. Вона обростає полями, потрібними тільки для UI, і методами, потрібними тільки для бізнес-логіки. ORM стягує зайві дані. Оптимізувати запис важко, бо це ламає читання, і навпаки.

Рішення: Розділяй і Володарюй

Бертран Мейєр (творця мови Eiffel) сформулював принцип CQS (Command-Query Separation) для методів класів:

"Функція повинна або змінювати стан об'єкта (Command), або повертати результат (Query), але не обидва одночасно."

CQRS переносить цей принцип на рівень Архітектури.

Ми створюємо дві окремі моделі:

  1. Command Model (Write Model): Оптимізована для зміни стану. Вона валідує правила, слідкує за консистенцією. Вона "сувора".
  2. Query Model (Read Model): Оптимізована для читання. Вона просто повертає дані, які хоче бачити UI. Вона може бути денормалізована. Вона "швидка".
Loading diagram...

graph TD User((User))

subgraph App [Application]
    CmdHandler[Command Handler<br/>Write Side]
    QryHandler[Query Handler<br/>Read Side]
end

DB[(Database)]

User -->|Command: RegisterUser| CmdHandler
User -->|Query: GetUserList| QryHandler

CmdHandler -->|Domain Logic| DB
QryHandler -->|Raw SQL / Projection| DB

style CmdHandler fill:#ffccbc,stroke:#d84315
style QryHandler fill:#c8e6c9,stroke:#2e7d32

Такий підхід дозволяє нам використовувати Polyglot Persistence (Багатомовне Збереження):

  • Ми пишемо в надійну PostgreSQL (відповідає ACID вимогам для транзакцій).
  • Ми читаємо з супер-швидкого Elasticsearch або Redis, куди дані потрапляють у готовому для відображення вигляді.

Анатомія CQRS

1. Модель Виконання Команд (Write Side)

Тут живе наш DDD. Агрегати, Сутності, Репозиторії. Це єдине джерело істини (Source of Truth).

  • Мета: Гарантувати консистенцію даних та інваріантів.
  • Поведінка: void Register(User user). Якщо щось не так — кидаємо Exception.
  • Повернення значень: В ідеалі — нічого (void). На практиці — ID створеної сутності або статус операції (Result Pattern). Але ніколи не повертайте DTO для відображення на UI.

2. Моделі Читання (Read Side / Projections)

Тут живе те, що потрібно інтерфейсу.

  • Мета: Максимально швидко віддати дані.
  • Поведінка: Ніякої бізнес-логіки. Ніякої валідації. Просто SELECT * FROM ... WHERE ....
  • Особливість: Моделі читання — це Проекції. Вони подібні до Materialized Views в SQL. Вони є кешем. Вони "лише для читання".

Проектування Моделей Читання

Як дані потрапляють з Command Side в Read Side? Є два шляхи.

Синхронні Проекції

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

  • Як: У тій же транзакції, де ми зберігаємо User, ми оновлюємо UserView.
  • Плюс: Простота. Дані завжди актуальні (Strong Consistency).
  • Мінус: Повільніший запис (бо треба писати в дві таблиці). Важко масштабувати.

Асинхронні Проекції (Event Base)

Коли Command Side змінює стан, вона публікує Подію (Domain Event: UserRegistered). Спеціальний обробник (Projector) слухає цю подію і оновлює Модель Читання (наприклад, додає запис в Redis).

Loading diagram...

sequenceDiagram participant User participant CommandHandler participant DB as Write DB participant EventBus participant Projector participant ReadDB

User->>CommandHandler: Command (Change Address)
CommandHandler->>DB: Update Aggregate
CommandHandler->>EventBus: Publish Event (AddressChanged)
EventBus->>Projector: Handle Event
Projector->>ReadDB: Update Read Model (UserView)

Note over User, ReadDB: Eventual Consistency (Узгодженість у кінцевому рахунку)

Евентуальна Узгодженість (Eventual Consistency): Це головна ціна асинхронності. Користувач може змінити ім'я, оновити сторінку і побачити старе ім'я, бо подія ще не долетіла до Read DB. Чи це проблема? Залежить від бізнесу. Для банківського рахунку — так. Для лайків у соцмережі — ні.

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

CQRS — це потужний інструмент, але він додає багато складності.

Застосовувати, коли:

  • Високе навантаження на читання: Співвідношення Read/Write > 100:1. Ви можете масштабувати Read Side (10 серверів Elastic) окремо від Write Side (1 майстер PostgreSQL).
  • Складний UI: Екран потребує даних з 10 різних агрегатів. Робити це через ORM і джойни в бізнес-моделі — біль. Краще мати готову плоску таблицю "під екран".
  • Event Sourcing: Якщо ви використовуєте Event Sourcing, CQRS є обов'язковим, бо ви не можете робити запити по подіях ефективно.

Уникати, коли:

  • Простий CRUD додаток.
  • Прості бізнес-правила.
  • Команда не готова до асинхронності та Eventual Consistency.


6. Event Sourcing (Подієва Орієнтація)

Ми не можемо говорити про CQRS і не згадати Event Sourcing (ES). Хоча це окремий патерн, вони часто ходять парою, як Бонні і Клайд.

Що не так зі зберіганням стану?

У традиційних базах даних ми зберігаємо поточний стан.

  • У таблиці Users є запис: { Id: 1, Balance: 100 }.
  • Якщо ми змінимо баланс, ми затремо старе значення: { Id: 1, Balance: 150 }.
  • Ми втратили інформацію: Як ми дійшли до 150? Це було поповнення на 50? Чи переказ на 100 і списання 50?

Event Sourcing пропонує інший підхід: Зберігати не стан, а події, що призвели до цього стану.

Потік Подій (Event Stream)

Замість одного запису в таблиці, ми маємо серію подій:

  1. AccountCreated { Id: 1, Owner: "Alice" }
  2. Deposited { Amount: 100 }
  3. Deposited { Amount: 50 }
  4. Withdrawn { Amount: 20 }

Щоб дізнатися поточний баланс, ми просто "програємо" (Replay) всі події з початку: 0 + 100 + 50 - 20 = 130.

Loading diagram...

graph LR E1AccountCreated --> E2Deposited +100 E2 --> E3Deposited +50 E3 --> E4Withdrawn -20

CurrentState((Current Balance: 130))

E4 -.->|Replay| CurrentState

style CurrentState fill:#b3e5fc,stroke:#0288d1

Переваги ES

  1. Аудит з коробки: Ви ніколи не губите дані. Ви знаєте все, що відбувалося з системою. Історія змін вбудована в архітектуру.
  2. Часова Машина: Ви можете відновити стан системи на будь-який момент часу в минулому. "Яким був баланс Аліси минулого вівторка о 14:00?" — просто програйте події до цього моменту.
  3. Аналітика: Ви можете аналізувати поведінку користувачів постфактум. "Як часто користувачі додають товар у кошик, а потім видаляють його?" Зі звичайною БД ви цього не дізнаєтесь (бо видалення затирає дані), з ES — легко.
  4. Спрощення CQRS: Write Side просто пише події (дуже швидко). Read Side підписується на події і будує будь-які проекції.

Недоліки ES (Чому це не для всіх)

  1. Складність: Це повна зміна мислення. Ніяких UPDATE чи DELETE. Тільки INSERT.
  2. Версіонування подій: Що робити, якщо ви змінили структуру події OrderPlaced? Вам потрібні стратегії міграції (Upcasting).
  3. Snapshots (Знімки): Якщо у вас 1 мільйон подій, програвати їх щоразу довго. Треба робити "знімки" стану кожні 100 подій.
  4. Event Store: Вам потрібна спеціалізована база даних (EventStoreDB) або налаштування існуючої (PostgreSQL) для ефективної роботи з потоками.
Event Sourcing !== Event Driven ArchitectureНе плутайте.
  • EDA: Сервіси спілкуються повідомленнями (RabbitMQ).
  • Event Sourcing: Стан всередині сервісу зберігається як серія подій. Ви можете мати ES без мікросервісів, і мікросервіси (EDA) без ES.

7. Архітектурні Анти-патерни

Знати, як не треба робити, так само важливо, як знати правильний шлях. Ось найпопулярніші архітектурні "граблі".

1. Велика Грудка Бруду (Big Ball of Mud)

Це відсутність архітектури.

  • Симптоми: Циклічні залежності, спагетті-код, ніхто не знає, де що лежить.
  • Лікування: Тільки переписування по частинах (Strangler Fig Pattern).

2. Лазанья-Архітектура (Lasagna Architecture)

Це Шарувата архітектура, доведена до абсурду.

  • Симптоми: Занадто багато шарів. Щоб передати рядок з UI в БД, треба пройти через Controller -> DTO -> Facade -> Service -> Domain -> Repository -> DAO -> ORM.
  • Наслідок: Кожна зміна вимагає редагування 10 файлів.
  • Лікування: Видаляйте непотрібні шари. Використовуйте Relaxed Layering.

3. Газопровід (Gas Pipeline)

Це коли у вас є мікросервіси, але вони настільки зв'язані, що один упав — все впало.

  • Симптоми: Довгі ланцюжки синхронних HTTP викликів (Service A -> Service B -> Service C).
  • Лікування: Асинхронність, черги повідомлень, кешування.

4. Анемічна Доменна Модель (Anemic Domain Model)

Ми говорили про це в попередніх главах, але це і архітектурна проблема.

  • Симптоми: Вся бізнес-логіка в Сервісах, а Сутності — це пусті "мішки для геттерів і сеттерів".
  • Наслідок: Порушення інкапсуляції, дублювання логіки.
  • Лікування: Переносьте логіку в Сутності (Rich Domain Model).

8. Тестування в різних Архітектурах

Ваша стратегія тестування напряму залежить від обраної архітектури.

Піраміда Тестування

Майк Кон запропонував ідею піраміди:

  • Unit Tests (Багато, швидкі)
  • Integration Tests (Менше, повільніші)
  • E2E Tests (Мало, дуже повільні)

Тестування в Шаруватій Архітектурі

Тут часто виникає проблема: Важко писати Unit-тести для BLL, бо він залежить від DAL.

  • Вам доводиться мокати базу даних або репозиторії.
  • Часто скочуються до Ice Cream Cone Anti-pattern (багато ручних/E2E тестів, мало юнітів), бо "все зав'язано на базу".

Тестування в Портах та Адаптерах

Це рай для тестувальника.

  • Unit Tests: Ви тестите Domain Entities та Domain Services без жодних моків інфраструктури. Це чиста Java/C# логіка.
  • Subcutaneous Tests (Підшкірні тести): Ви тестите Application Use Cases, підміняючи порти фейками (FakeRepository). Ви перевіряєте бізнес-сценарії повністю, не піднімаючи БД.
  • Integration Tests: Перевіряють тільки Адаптери (чи правильно SqlOrderRepository пише в PostgreSQL).
ПорадаЯкщо ваша архітектура дозволяє запустити і протестувати всю бізнес-логіку без docker-compose up — ви на правильному шляху.


9. Практикум: Рефакторинг Legacy в Чисту Архітектуру

Припустимо, у вас є типовий контролер на 1000 рядків, який робить все підряд. Як перейти до Портів та Адаптерів?

Крок 1: Виділення Сервісу (Extraction)

Винесіть всю логіку з контролера в LegacyUserService. Контролер має стати тонким (лише HTTP -> Service -> HTTP). Це ще не DDD, це просто прибирання сміття.

Крок 2: Інверсія Залежностей (DIP)

Подивіться, від чого залежить LegacyUserService. Від DbContext?

  1. Створіть інтерфейс IUserRepository (Порт) з методами, які ви використовуєте.
  2. Створіть реалізацію SqlUserRepository (Адаптер), перенісши туди EF код.
  3. Впровадьте IUserRepository в сервіс через конструктор.

Крок 3: Виділення Доменної Моделі

Зараз ваш сервіс, ймовірно, маніпулює даними напряму ("Transaction Script"). Знайдіть бізнес-правила (наприклад, "користувач не може мати від'ємний баланс"). Перенесіть цю логіку в сутність User. Сервіс має лише завантажувати сутність, викликати метод сутності і зберігати її.

Крок 4: Розділення Command і Query (CQS)

Якщо у вас є методи, які лише читають дані для UI — винесіть їх в окремі UserQueryService або Query Handlers. Нехай вони повертають плоскі DTO. Очистіть основний сервіс від методів читання.

10. Часті Питання (FAQ)


11. Область Застосування та Гібридні Підходи

На завершення, важливо пам'ятати: Вам не потрібно обирати один патерн для всієї системи.

Сучасна архітектура — це комбінація.

  • Core Domain (там де гроші): Використовуйте Порти та Адаптери + DDD + можливо, CQRS (якщо складно). Тут потрібна максимальна гнучкість і чистота.
  • Generic Subdomain (адмінка, налаштування): Використовуйте просту Шарувату Архітектуру або просто CRUD. Тут не треба мудрувати.
  • Supporting Subdomain (ETL процеси): Можливо, просто Transaction Scripts.

Vertical Slices (Вертикальні Зрізи)

Замість того, щоб ділити код горизонтально (всі контролери разом, всі сервіси разом), спробуйте ділити його вертикально — по фічах (Features).

У кожній фічі (Features/Orders/PlaceOrder, Features/Products/GetCatalog) ви можете обрати той архітектурний підхід, який підходить саме їй. Одна фіча може бути складною (DDD), інша — простою (SQL Script).

Архітектурна Еволюція

Не бійтеся починати з простого.

  1. Почніть з Шаруватої Архітектури.
  2. Коли логіка ускладниться, додайте Сервісний Шар.
  3. Коли залежність від БД стане проблемою, відрефакторіть ядро в Порти та Адаптери.
  4. Коли запити на читання стануть повільними, винесіть їх в окремі CQRS Проекції.

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

Підсумок

  1. Шарувата Архітектура: Хороший старт, але обережно із залежностями від БД.
  2. Порти та Адаптери: Золотий стандарт для складного DDD. Інвертує залежності, оберігаючи бізнес-логіку.
  3. CQRS: Потужний інструмент для оптимізації читання і запису окремо. Використовуйте з розумом, пам'ятаючи про ціну складності.

Перевірка Знань

Примітка: Якщо тест не відображається, перейдіть за прямим посиланням.
Copyright © 2026