"Архітектура — це рішення, які важко змінити пізніше. Тому варто приділити їм увагу зараз." — Мартін Фаулер
У попередніх главах ми фокусувалися на тактичних патернах (Tactical Patterns) — будівельних блоках, які допомагають нам моделювати бізнес-логіку всередині одного Обмеженого Контексту (Bounded Context). Ми говорили про Агрегати, Сутності, Об'єкти-Значення та Доменні Сервіси. Це наші "цеглинки" та "розчин".
Але як нам скласти ці цеглинки в стійку будівлю? Як організувати взаємодію між бізнес-логікою, базою даних, користувацьким інтерфейсом та зовнішніми API так, щоб система не перетворилася на "Велику Грудку Бруду" (Big Ball of Mud)?
У цій главі ми піднімемося на рівень вище і розглянемо архітектурні патерни. Це стратегії організації кодової бази, які визначають, як різні частини системи спілкуються одна з одною.
Уявіть, що ви пишете простий скрипт для автоматизації. Ви змішуєте логіку (як обробити дані), введення (звідки взяти дані) і виведення (куди покласти результат) в одному файлі. Це працює для 100 рядків коду.
Тепер уявіть Enterprise-систему на 500,000 рядків коду. Якщо бізнес-логіка напряму викликає базу даних, а UI напряму звертається до бізнес-правил, які, в свою чергу, малюють HTML — ви отримуєте систему, яку неможливо протестувати, неможливо змінити і страшно розгортати.
Жорстка Зв'язність (High Coupling)
Низька Зв'язність (Low Cohesion)
Архітектурні патерни дають нам:
Ми розглянемо три фундаментальні підходи, кожен з яких еволюціонував з попереднього:
Класичний підхід "торта". Простий для розуміння, ідеальний для простих CRUD-додатків, але має свої обмеження.
Також відома як Гексагональна або Чиста Архітектура. Інвертує залежності, ставлячи домен у центр всесвіту.
Розділення системи на дві частини: одну для зміни стану (Write), іншу для читання (Read). Вища ліга для складних доменів.
Бізнес-логіка — це серце вашого програмного забезпечення, але це не єдиний орган. Системі потрібні "руки" (UI), "пам'ять" (DB) та "вуха" (API).
Різноманітність завдань створює спокусу розподілити бізнес-логіку де попало:
Це шлях до пекла підтримки (Maintenance Hell).
Архітектурні патерни вводять правила гри. Вони кажуть: "Ти, UI, можеш говорити тільки з Сервісом. А ти, Сервіс, нічого не знаєш про Базу Даних напряму".
Це найстаріший, найвідоміший і досі найпопулярніший патерн. Якщо ви коли-небудь створювали папку Models, Views та Controllers (MVC) — ви вже частково знайомі з ідеєю шарів, хоча N-Tier архітектура йде трохи далі.
Уявіть геологічні шари землі або, що смачніше, торт "Наполеон". Кожен шар лежить на іншому. Верхній шар спирається на нижній, але нижній шар нічого не знає про верхній.
У класичній формі ми маємо три основні шари (Layers):
Це "обличчя" вашої системи. Тут відбувається взаємодія з зовнішнім світом.
Що тут живе:
Відповідальність:
if (user.balance < price), ніяких розрахунків знижок. Його робота — бути перекладачем і диспетчером.Це "мозок" системи. Тут приймаються рішення.
Що тут живе:
Відповідальність:
Як зазначає Ерік Еванс, цей шар є серцем програмного забезпечення. Саме тут реалізуються патерни, які ми розглядали в главах 5-7 (Active Record або Domain Model).
Це "сховище" системи.
Що тут живе:
Відповідальність:
!NOTE У сучасних реаліях DAL — це не тільки SQL база даних. Це може бути NoSQL (MongoDB), пошукового індексу (Elasticsearch), кешу (Redis) або навіть хмарного сховища файлів (S3). Для шару бізнес-логіки деталі реалізації DAL мають бути сховані за абстракцією (хоча в класичній Layed Architecture це часто порушується).
Головне правило Шаруватої Архітектури: Залежності спрямовані зверху вниз.
Це забезпечує певну ізоляцію. Якщо ви зміните верстку HTML у PL, це не вплине на BLL. Якщо ви оптимізуєте SQL запит у DAL, BLL і PL про це навіть не дізнаються (теоретично).
Часто трьох шарів недостатньо. Коли бізнес-логіка складна, контролери стають занадто товстими, або бізнес-сутності починають займатися оркестрацією. Тут на сцену виходить Сервісний Шар.
Це додатковий прошарок між PL та BLL.
"Сервісний шар визначає межу додатка і набір доступних операцій з точки зору клієнтів інтерфейсу." — Мартін Фаулер
Він виступає як Оркестратор. Він не містить "чистої" бізнес-логіки (правил розрахунку цін, валідації стану сутності), він містить логіку сценаріїв використання (Use Case Logic).
Типовий код методу в Service Layer:
Розглянемо приклад створення користувача.
// 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);
}
}
}
Проблеми:
DbContext).Ми створюємо 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);
}
}
Переваги:
UserService можна викликати з MVC, Web API, gRPC, Console App.UserService легко протестувати юніт-тестами, замокавши репозиторій.Це чудовий старт.
✅ За:
❌ Проти:
У різних джерелах шари називаються по-різному. Ось "словник перекладу":
| Ваше джерело | Ця книга (DDD) | Альтернативи |
|---|---|---|
| Presentation | Presentation Layer | UI Layer, Web Layer, Interface Layer |
| Logic | Business Logic Layer | Domain Layer, Model Layer, Core |
| Application | Service Layer | Application Layer, Use Case Layer |
| Data | Data Access Layer | Infrastructure Layer, Persistence Layer |
Класична шарувата архітектура має фатальний недолік для складних доменів: залежність бізнесу від даних. Уявіть, що ваша бізнес-логіка (найцінніше, що у вас є) залежить від бібліотеки доступу до бази даних (найбільш мінливої і технічної частини). Якщо ви хочете замінити SQL на NoSQL, вам доведеться переписувати бізнес-логіку або принаймні перекомпілювати її. Це неправильно.
Принцип Інверсії Залежностей (Dependency Inversion Principle - DIP) каже:
"Високорівневі модулі не повинні залежати від низькорівневих. Обидва повинні залежати від абстракцій."
Архітектура Порти та Адаптери (також відома як Гексагональна Архітектура, Цибулева Архітектура, Чиста Архітектура) вирішує цю проблему радикально.
Ми перестаємо думати про систему як про "пиріг" шарів (зверху вниз). Ми думаємо про неї як про Ядро (Core), яке знаходиться в центрі, та Зовнішній Світ (Infrastructure), який його оточує.
Уявіть ігрову консоль.
У цій архітектурі Бізнес-Логіка стає центром всесвіту. Вона не залежить ні від чого. Вона визначає правила гри.
Зверніть увагу на стрілки на діаграмі. Всі залежності спрямовані всередину, до Ядра. Інфраструктура залежить від Домену. Домен не залежить від Інфраструктури.
Як це працює на рівні коду? Давайте порівняємо.
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);
}
}
}
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();
}
}
}
Поняття "Порти та Адаптери" покриває два напрямки комунікації:
Це те, що викликає вашу програму. Вони "керують" вашим додатком.
OrderService.PlaceOrder().Це те, що викликається вашою програмою. Вони "керовані" вашим додатком.
SqlOrderRepository реалізує IOrderRepository. SmtpEmailSender реалізує IEmailSender.Давайте розберемося в цьому зоопарку назв. Вони описують одну й ту саму суть, але з різними метафорами.
Гексагональна Архітектура (Alistair Cockburn)
Цибулева Архітектура (Jeffrey Palermo)
Розвиває ідею кілець.
Чиста Архітектура (Uncle Bob)
Найбільш відома версія. Об'єднує ідеї попередніх.
Суть у них одна й та сама: Бізнес-логіка ізольована від деталей реалізації.
Цей патерн ідеальний для систем, де ви використовуєте Domain Driven Design.
✅ Переваги:
InMemoryOrderRepository або Mock. Ваші тести будуть літати (мілісекунди замість секунд).❌ Недоліки:
CQRS — це один з найбільш неправильно зрозумілих патернів. Багато хто думає, що це про "дві бази даних" або "складну магію з чергами". Насправді, в основі лежить дуже проста ідея про розподіл обов'язків.
У звичайних системах ми використовуємо одну й ту саму модель (клас) і для читання, і для запису.
User.PasswordHash, Salt, RegistrationDate, IsActive.FullName, LastLogin, але точно не потрібен PasswordHash.З часом наша модель User стає монстром. Вона обростає полями, потрібними тільки для UI, і методами, потрібними тільки для бізнес-логіки. ORM стягує зайві дані. Оптимізувати запис важко, бо це ламає читання, і навпаки.
Бертран Мейєр (творця мови Eiffel) сформулював принцип CQS (Command-Query Separation) для методів класів:
"Функція повинна або змінювати стан об'єкта (Command), або повертати результат (Query), але не обидва одночасно."
CQRS переносить цей принцип на рівень Архітектури.
Ми створюємо дві окремі моделі:
Такий підхід дозволяє нам використовувати Polyglot Persistence (Багатомовне Збереження):
Тут живе наш DDD. Агрегати, Сутності, Репозиторії. Це єдине джерело істини (Source of Truth).
void Register(User user). Якщо щось не так — кидаємо Exception.Тут живе те, що потрібно інтерфейсу.
SELECT * FROM ... WHERE ....Materialized Views в SQL. Вони є кешем. Вони "лише для читання".Як дані потрапляють з Command Side в Read Side? Є два шляхи.
При оновленні даних ми одразу оновлюємо і таблицю для читання, і таблицю для запису в одній транзакції.
User, ми оновлюємо UserView.Коли Command Side змінює стан, вона публікує Подію (Domain Event: UserRegistered).
Спеціальний обробник (Projector) слухає цю подію і оновлює Модель Читання (наприклад, додає запис в Redis).
Евентуальна Узгодженість (Eventual Consistency): Це головна ціна асинхронності. Користувач може змінити ім'я, оновити сторінку і побачити старе ім'я, бо подія ще не долетіла до Read DB. Чи це проблема? Залежить від бізнесу. Для банківського рахунку — так. Для лайків у соцмережі — ні.
CQRS — це потужний інструмент, але він додає багато складності.
✅ Застосовувати, коли:
❌ Уникати, коли:
Ми не можемо говорити про CQRS і не згадати Event Sourcing (ES). Хоча це окремий патерн, вони часто ходять парою, як Бонні і Клайд.
У традиційних базах даних ми зберігаємо поточний стан.
Users є запис: { Id: 1, Balance: 100 }.{ Id: 1, Balance: 150 }.Event Sourcing пропонує інший підхід: Зберігати не стан, а події, що призвели до цього стану.
Замість одного запису в таблиці, ми маємо серію подій:
AccountCreated { Id: 1, Owner: "Alice" }Deposited { Amount: 100 }Deposited { Amount: 50 }Withdrawn { Amount: 20 }Щоб дізнатися поточний баланс, ми просто "програємо" (Replay) всі події з початку:
0 + 100 + 50 - 20 = 130.
UPDATE чи DELETE. Тільки INSERT.OrderPlaced? Вам потрібні стратегії міграції (Upcasting).Знати, як не треба робити, так само важливо, як знати правильний шлях. Ось найпопулярніші архітектурні "граблі".
Це відсутність архітектури.
Це Шарувата архітектура, доведена до абсурду.
Це коли у вас є мікросервіси, але вони настільки зв'язані, що один упав — все впало.
Service A -> Service B -> Service C).Ми говорили про це в попередніх главах, але це і архітектурна проблема.
Ваша стратегія тестування напряму залежить від обраної архітектури.
Майк Кон запропонував ідею піраміди:
Тут часто виникає проблема: Важко писати Unit-тести для BLL, бо він залежить від DAL.
Це рай для тестувальника.
FakeRepository). Ви перевіряєте бізнес-сценарії повністю, не піднімаючи БД.SqlOrderRepository пише в PostgreSQL).docker-compose up — ви на правильному шляху.Припустимо, у вас є типовий контролер на 1000 рядків, який робить все підряд. Як перейти до Портів та Адаптерів?
Винесіть всю логіку з контролера в LegacyUserService. Контролер має стати тонким (лише HTTP -> Service -> HTTP).
Це ще не DDD, це просто прибирання сміття.
Подивіться, від чого залежить LegacyUserService. Від DbContext?
IUserRepository (Порт) з методами, які ви використовуєте.SqlUserRepository (Адаптер), перенісши туди EF код.IUserRepository в сервіс через конструктор.Зараз ваш сервіс, ймовірно, маніпулює даними напряму ("Transaction Script").
Знайдіть бізнес-правила (наприклад, "користувач не може мати від'ємний баланс"). Перенесіть цю логіку в сутність User.
Сервіс має лише завантажувати сутність, викликати метод сутності і зберігати її.
Якщо у вас є методи, які лише читають дані для UI — винесіть їх в окремі UserQueryService або Query Handlers. Нехай вони повертають плоскі DTO.
Очистіть основний сервіс від методів читання.
На завершення, важливо пам'ятати: Вам не потрібно обирати один патерн для всієї системи.
Сучасна архітектура — це комбінація.
Замість того, щоб ділити код горизонтально (всі контролери разом, всі сервіси разом), спробуйте ділити його вертикально — по фічах (Features).
У кожній фічі (Features/Orders/PlaceOrder, Features/Products/GetCatalog) ви можете обрати той архітектурний підхід, який підходить саме їй. Одна фіча може бути складною (DDD), інша — простою (SQL Script).
Не бійтеся починати з простого.
Найгірша архітектура — це та, яка вирішує проблеми, яких у вас ще немає.
Підсумок
Моделювання фактора часу
Event Sourcing pattern для моделювання темпоральної dimensії в Domain-Driven Design
Глава 9. Патерни Взаємодії
У попередніх розділах (глави 5–8) ми фокусувалися на тактичних патернах — як будувати окремі компоненти системи, моделювати бізнес-логіку всередині одного Обмеженого Контексту (Bounded Context). Ми навчилися створювати Агрегати, Об'єкти-Значення та організовувати їх у Шарувату або Гексагональну архітектуру.