Пошук уроків, статей та іншого контенту
Вивчаємо концентричні шари, правило залежностей і випадки, коли чиста архітектура виправдана або надмірна.
Clean Architecture — це підхід до побудови систем, у якому бізнес-правила захищені від деталей реалізації:
фреймворків;
баз даних;
HTTP;
UI;
зовнішніх API;
конкретних бібліотек.
Основна ідея: система поділяється на шари, а залежності між ними спрямовані всередину, до бізнес-правил.
Це дає змогу змінити базу даних, HTTP-фреймворк або спосіб запуску програми з мінімальним впливом на бізнес-логіку.
Чисту архітектуру часто зображають як набір концентричних кіл.
Центральний шар містить найважливіші бізнес-правила, які існують незалежно від конкретного застосунку.
Наприклад, для інтернет-магазину сутність Order може визначати:
замовлення не може бути порожнім;
загальна сума повинна бути додатною;
замовлення не можна оплатити двічі.
Сутність не повинна знати:
як зберігаються дані;
який використовується HTTP-фреймворк;
яка база даних підключена;
Цей шар описує операції, які система дозволяє виконувати.
Приклади:
створити замовлення;
оплатити рахунок;
зареєструвати користувача;
скасувати підписку.
Use case координує сутності та визначає послідовність дій. Він може звертатися до репозиторію або сервісу, але не повинен залежати від конкретної бази даних чи фреймворку.
Адаптери перетворюють дані між зовнішнім світом і внутрішньою моделлю.
Приклади:
HTTP-контролер перетворює запит на вхідні дані use case;
presenter перетворює результат use case у формат відповіді;
repository adapter перетворює записи бази даних на сутності;
CLI-адаптер перетворює аргументи командного рядка на вхідні дані.
Це зовнішній шар, де розташовані технічні деталі:
Express, Fastify або інший HTTP-фреймворк;
PostgreSQL, MongoDB або Redis;
драйвери бази даних;
веб-сервер;
файлове сховище.
Цей шар є змінним і не повинен визначати бізнес-правила системи.
Головне правило чистої архітектури:
Залежності у вихідному коді повинні бути спрямовані лише всередину.
Зовнішній шар може залежати від внутрішнього:
Frameworks & Drivers → Adapters → Use Cases → EntitiesАле внутрішній шар не повинен імпортувати зовнішній:
Entity → Express // неправильно
Use Case → PostgreSQL // неправильно
Use Case → Repository // правильно, якщо це абстракціяВажливо розрізняти:
потік виконання — що викликається під час роботи програми;
напрям залежностей у коді — які модулі імпортують один одного.
Наприклад, HTTP-контролер може викликати use case, але use case не повинен знати, що його викликали через HTTP.
Часто use case потребує доступу до даних. Якщо він напряму використовує клас PostgreSQL-репозиторію, бізнес-логіка стає залежною від бази даних.
Замість цього внутрішній шар визначає контракт:
OrderRepository
├── save(order)
└── findById(id)Use case працює з цим контрактом, а зовнішній шар надає конкретну реалізацію:
InMemoryOrderRepository
PostgresOrderRepository
FileOrderRepositoryЦе називається інверсією залежностей:
абстракція належить внутрішньому шару;
конкретна реалізація належить зовнішньому шару;
зовнішня реалізація підключається до use case ззовні.
У TypeScript контракт зазвичай описують через interface, у JavaScript — через домовленість про методи об'єкта.
Нижче наведено спрощений, але runnable-приклад на JavaScript. Він містить:
сутність Order;
use case CreateOrder;
порт репозиторію;
адаптер репозиторію в пам'яті;
контролер, який імітує HTTP-запит.
Внутрішня логіка не знає про HTTP і конкретне сховище.
class Order {
constructor({ id, customerId, items }) {
if (!customerId) {
throw new Error("customerId є обов'язковим");
}
if (!Array.isArray(items) || items.length === 0) {
throw new Error("Замовлення повинно містити хоча б один товар");
}
const hasInvalidItem = items.some(
(item) =>
!item.productId ||
!Number.isInteger(item.quantity) ||
item.quantity <= 0 ||
typeof item.price !== "number" ||
item.price < 0
);
if (hasInvalidItem) {
throw new Error("Замовлення містить некоректний товар");
}
this.id = id;
this.customerId = customerId;
this.items = items;
this.total = items.reduce(
(sum, item) => sum + item.quantity * item.price,
0
);
}
}
class CreateOrder {
constructor(orderRepository, idGenerator) {
this.orderRepository = orderRepository;
this.idGenerator = idGenerator;
}
async execute(input) {
const order = new Order({
id: this.idGenerator(),
customerId: input.customerId,
items: input.items
});
await this.orderRepository.save(order);
return {
id: order.id,
customerId: order.customerId,
total: order.total
};
}
}
class InMemoryOrderRepository {
constructor() {
this.orders = new Map();
}
async save(order) {
this.orders.set(order.id, order);
}
async findById(id) {
return this.orders.get(id) ?? null;
}
}
class CreateOrderController {
constructor(createOrder) {
this.createOrder = createOrder;
}
async handle(request) {
try {
const result = await this.createOrder.execute({
customerId: request.body.customerId,
items: request.body.items
});
return {
statusCode: 201,
body: result
};
} catch (error) {
return {
statusCode: 400,
body: {
error: error.message
}
};
}
}
}
const repository = new InMemoryOrderRepository();
const createOrder = new CreateOrder(
repository,
() => `order-${Date.now()}`
);
const controller = new CreateOrderController(createOrder);
const response = await controller.handle({
body: {
customerId: "customer-42",
items: [
{ productId: "keyboard", quantity: 2, price: 50 },
{ productId: "mouse", quantity: 1, price: 25 }
]
}
});
console.log(response);
// {
// statusCode: 201,
// body: { id: 'order-...', customerId: 'customer-42', total: 125 }
// }У цьому прикладі:
Order не знає про репозиторій або контролер;
CreateOrder не знає про HTTP;
InMemoryOrderRepository є зовнішнім адаптером;
контролер перетворює зовнішній запит на виклик use case;
репозиторій можна замінити на реалізацію для PostgreSQL без зміни Order і CreateOrder.
Межа між шарами — це місце, де один компонент спілкується з іншим через визначений контракт.
Наприклад, use case може приймати простий об'єкт:
{
customerId: "customer-42",
items: [
{
productId: "keyboard",
quantity: 2,
price: 50
}
]
}Він не повинен приймати об'єкт запиту конкретного фреймворку:
request.body
request.params
request.userТак use case можна викликати:
з HTTP-контролера;
з фонової задачі;
з CLI-команди;
безпосередньо з тесту.
Адаптер на межі відповідає за перетворення формату.
До бізнес-логіки належать правила, через порушення яких операція стає некоректною для предметної області.
Наприклад:
замовлення має містити товар;
користувач не може скасувати вже завершену операцію;
знижка не може перевищувати встановлений ліміт;
переказ не може перевищувати доступний баланс.
До технічних деталей належать:
SQL-запит;
HTTP-статус;
формат JSON;
назва таблиці;
спосіб серіалізації;
конкретний клас помилки фреймворку.
Технічні деталі можуть змінюватися, а бізнес-правила бажано залишати стабільними.
Чиста архітектура найкраще виправдовує себе, коли:
бізнес-логіка складна;
у системі багато бізнес-правил;
продукт розвиватиметься протягом тривалого часу;
планується заміна бази даних або зовнішніх сервісів;
одна бізнес-логіка використовується через кілька інтерфейсів;
потрібні ізольовані тести без запуску всієї інфраструктури;
помилки в бізнес-логіці мають високу ціну.
Наприклад, для платіжної системи відокремлення правил оплати від HTTP і бази даних може істотно спростити перевірку коректності операцій.
Для невеликого застосунку чиста архітектура може створити більше коду, ніж користі.
Вона часто надмірна, якщо:
застосунок має кілька простих CRUD-операцій;
бізнес-правил майже немає;
продукт є короткостроковим прототипом;
система підтримується однією людиною;
додаткові абстракції не захищають жодної складної логіки.
Наприклад, для простої внутрішньої форми з одним екраном окремі сутності, use case, порти, адаптери й кілька рівнів мапінгу можуть лише ускладнити код.
Чиста архітектура не є вимогою для кожного проєкту. Її слід застосовувати як інструмент для керування складністю, а не як набір обов'язкових папок.
Перед додаванням нового шару варто запитати:
Яку залежність цей шар ізолює?
Яка бізнес-логіка буде захищена?
Чи стане тестування простішим?
Чи очікується заміна ізольованої деталі?
Чи зменшиться зв'язність модулів?
Якщо на всі запитання відповідь негативна, абстракція може бути зайвою.
Не обов'язково відразу будувати всі шари. Їх можна додавати поступово, коли з'являється реальна потреба в ізоляції бізнес-правил.
Один із можливих варіантів структури:
src/
├── domain/
│ └── order.js
├── application/
│ └── create-order.js
├── adapters/
│ ├── controllers/
│ │ └── create-order-controller.js
│ └── repositories/
│ └── postgres-order-repository.js
└── infrastructure/
├── http-server.js
└── database.jsНазви папок не є суттю підходу. Важливіше, щоб:
домен не імпортував адаптери;
use case не залежав від фреймворку;
конкретні реалізації підключалися на зовнішньому рівні;
межі між компонентами були явними.
class CreateOrder {
async execute(request, response) {
// ...
}
}Такий use case залежить від HTTP-моделі. Краще приймати звичайні вхідні дані й повертати результат, а HTTP обробляти в контролері.
class CreateOrder {
async execute(input) {
await postgres.query("INSERT INTO orders ...");
}
}Тепер бізнес-логіку складно протестувати без PostgreSQL і неможливо легко замінити сховище.
Краще передати use case об'єкт із методом save.
Рядок таблиці або ORM-модель не обов'язково є доменною сутністю. Модель бази даних описує спосіб зберігання, а сутність — правила предметної області.
Іноді вони можуть мати схожу форму, але це не означає, що вони повинні бути одним класом.
Не кожен простий метод потребує окремого інтерфейсу, фабрики, адаптера та базового класу. Абстракція має ізолювати змінну або складну частину системи, а не просто збільшувати кількість файлів.
Якщо контролер перевіряє всі бізнес-правила, змінює стан сутностей і виконує кілька операцій над базою даних, логіка буде прив'язана до конкретного способу взаємодії із системою.
Контролер має переважно:
отримати зовнішні дані;
перетворити їх у формат use case;
викликати use case;
перетворити результат у зовнішню відповідь.
Чиста архітектура захищає бізнес-правила від технічних деталей.
Її шари зазвичай включають сутності, use cases, адаптери та фреймворки з драйверами.
Правило залежностей спрямовує залежності від зовнішніх шарів до внутрішніх.
Use case не повинен знати про HTTP, базу даних або конкретний фреймворк.
Інверсія залежностей дає змогу використовувати абстракції всередині та конкретні реалізації зовні.
Чиста архітектура корисна для складних і довгоживучих систем, але може бути надмірною для простих застосунків.
Головна мета підходу — не певна структура папок, а контроль залежностей і захист бізнес-логіки.