Пошук уроків, статей та іншого контенту
Відокремлюємо домен від адаптерів через порти та визначаємо, коли потрібна тестованість і незалежність від інфраструктури.
Гексагональна архітектура — це спосіб організації коду, за якого бізнес-логіка ізольована від деталей інфраструктури:
баз даних;
HTTP-фреймворків;
черг повідомлень;
файлової системи;
зовнішніх API;
конкретних бібліотек.
Її також називають архітектурою портів і адаптерів (Ports and Adapters).
Основна ідея:
Доменний код не повинен знати, звідки надходять дані та куди вони зберігаються.
Наприклад, застосунок може створювати замовлення через HTTP API, CLI або обробник повідомлень із черги. Для домену це лише різні способи викликати одну й ту саму операцію.
Так само замість PostgreSQL можна тимчасово використовувати пам’ять, файл або тестову реалізацію. Доменний код при цьому не змінюється.
Гексагональна архітектура складається з трьох ключових елементів:
Домен і застосункові сценарії — внутрішня логіка системи.
Порти — контракти взаємодії із зовнішнім світом.
Адаптери — конкретні реалізації взаємодії через ці порти.
Схематично залежності мають вигляд:
Зовнішній світ
|
v
Вхідні адаптери -> Вхідні порти -> Застосункова логіка
|
v
Вихідні порти
|
v
Вихідні адаптериВажливо, що напрямок залежностей спрямований до центру, а не навпаки.
Домен не імпортує HTTP-сервер, ORM чи клієнт бази даних. Натомість адаптери підлаштовуються під порти, визначені застосунком.
Домен містить правила предметної області. Наприклад:
замовлення не може бути порожнім;
заборонено створювати замовлення з від’ємною кількістю товарів;
статус замовлення можна змінити лише певним чином;
знижка застосовується за конкретних умов.
Доменна логіка не повинна залежати від того, як саме викликається код.
Застосунковий сценарій координує виконання операції:
перевіряє вхідні дані;
отримує необхідні дані через порт;
викликає доменну логіку;
зберігає результат через інший порт;
повертає результат виклику.
Наприклад, сценарій CreateOrder може створювати замовлення, але не повинен самостійно виконувати SQL-запит.
Порт — це контракт, через який внутрішня частина системи взаємодіє із зовнішньою.
Порт описує, що потрібно зробити, але не описує, як саме це зробити.
Наприклад:
class OrderRepository {
async save(order) {
throw new Error('Метод save має бути реалізований адаптером');
}
async findById(id) {
throw new Error('Метод findById має бути реалізований адаптером');
}
}Це порт репозиторію замовлень. Він не знає:
чи використовується PostgreSQL;
чи зберігаються дані в пам’яті;
чи виконується запит до віддаленого сервісу.
У мовах із явними інтерфейсами порт може бути представлений інтерфейсом. У JavaScript роль порту часто виконує угода, абстрактний клас або набір методів, які очікує застосунковий сервіс.
Вхідні порти описують операції, які система дозволяє викликати ззовні.
Наприклад:
createOrder;
cancelOrder;
getOrder.
Вхідний адаптер викликає вхідний порт, але не повинен містити основну бізнес-логіку.
Вихідні порти описують залежності, потрібні внутрішній логіці.
Наприклад:
репозиторій замовлень;
сервіс надсилання повідомлень;
генератор ідентифікаторів;
платіжний сервіс.
Застосунковий сценарій залежить від такого контракту, а конкретний адаптер його реалізує.
Адаптер перетворює зовнішній формат даних у формат, зрозумілий системі, або навпаки.
Вхідний адаптер запускає застосунковий сценарій.
Прикладами є:
HTTP-контролер;
CLI-команда;
обробник повідомлення з черги;
GraphQL-резолвер.
Його завдання:
отримати дані у зовнішньому форматі;
перетворити їх на команду або DTO;
викликати вхідний порт;
перетворити результат на відповідь для клієнта.
Вихідний адаптер реалізує порт, потрібний внутрішньому коду.
Прикладами є:
PostgresOrderRepository;
InMemoryOrderRepository;
EmailNotificationAdapter;
StripePaymentAdapter.
Два адаптери можуть реалізовувати один порт, але використовувати різні технології.
Для сценарію створення замовлення структура може виглядати так:
src/
├── domain/
│ └── order.js
├── application/
│ ├── ports/
│ │ └── order-repository.js
│ └── create-order.js
├── adapters/
│ ├── inbound/
│ │ └── http-order-controller.js
│ └── outbound/
│ └── in-memory-order-repository.js
└── server.jsЦе не єдина правильна структура. Важливіше не розташування файлів, а напрямок залежностей:
domain не залежить від adapters;
application залежить лише від контрактів;
adapters залежать від application та зовнішніх бібліотек.
Нижче наведено самодостатній приклад на JavaScript без сторонніх бібліотек. Він демонструє:
доменну модель замовлення;
вхідний порт у вигляді застосункового сервісу;
вихідний порт репозиторію;
адаптер репозиторію в пам’яті;
вхідний HTTP-адаптер.
Збережіть код у файл server.js і запустіть командою node server.js.
const http = require('node:http');
const { randomUUID } = require('node:crypto');
// Доменна модель не знає про HTTP, базу даних або Node.js
class Order {
constructor({ id, customerId, items, status = 'pending' }) {
if (!customerId) {
throw new Error('customerId є обов’язковим');
}
if (!Array.isArray(items) || items.length === 0) {
throw new Error('Замовлення має містити хоча б один товар');
}
for (const item of items) {
if (!item.productId || !Number.isInteger(item.quantity) || item.quantity <= 0) {
throw new Error('Кожен товар має мати productId і додатну quantity');
}
}
this.id = id;
this.customerId = customerId;
this.items = items;
this.status = status;
}
}
// Вихідний порт: застосунок очікує саме такі операції
class OrderRepository {
async save(order) {
throw new Error('Метод save має бути реалізований адаптером');
}
async findById(id) {
throw new Error('Метод findById має бути реалізований адаптером');
}
}
// Вихідний адаптер: конкретне зберігання в пам’яті
class InMemoryOrderRepository extends OrderRepository {
constructor() {
super();
this.orders = new Map();
}
async save(order) {
this.orders.set(order.id, order);
return order;
}
async findById(id) {
return this.orders.get(id) || null;
}
}
// Вхідний порт: застосунковий сценарій
class CreateOrder {
constructor({ orderRepository, idGenerator = randomUUID }) {
this.orderRepository = orderRepository;
this.idGenerator = idGenerator;
}
async execute({ customerId, items }) {
const order = new Order({
id: this.idGenerator(),
customerId,
items
});
return this.orderRepository.save(order);
}
}
// Вхідний адаптер: HTTP перетворює запит на виклик застосункового сценарію
function createHttpServer(createOrder) {
return http.createServer(async (request, response) => {
if (request.method !== 'POST' || request.url !== '/orders') {
response.writeHead(404, { 'Content-Type': 'application/json' });
response.end(JSON.stringify({ error: 'Маршрут не знайдено' }));
return;
}
try {
const body = await readJsonBody(request);
const order = await createOrder.execute(body);
response.writeHead(201, { 'Content-Type': 'application/json' });
response.end(JSON.stringify(order));
} catch (error) {
response.writeHead(400, { 'Content-Type': 'application/json' });
response.end(JSON.stringify({ error: error.message }));
}
});
}
// Допоміжна функція HTTP-адаптера для читання JSON
function readJsonBody(request) {
return new Promise((resolve, reject) => {
let data = '';
request.on('data', (chunk) => {
data += chunk;
});
request.on('end', () => {
try {
resolve(JSON.parse(data));
} catch {
reject(new Error('Некоректний JSON'));
}
});
request.on('error', reject);
});
}
const orderRepository = new InMemoryOrderRepository();
const createOrder = new CreateOrder({ orderRepository });
const server = createHttpServer(createOrder);
server.listen(3000, () => {
console.log('Сервер запущено на http://localhost:3000');
});Після запуску можна створити замовлення:
curl -X POST http://localhost:3000/orders \
-H "Content-Type: application/json" \
-d '{"customerId":"customer-1","items":[{"productId":"book-1","quantity":2}]}'Очікувана відповідь матиме приблизно такий вигляд:
{
"id": "згенерований-ідентифікатор",
"customerId": "customer-1",
"items": [
{
"productId": "book-1",
"quantity": 2
}
],
"status": "pending"
}Клас OrderRepository описує вихідний порт. Застосунковий сценарій CreateOrder працює з ним через метод save.
CreateOrder не знає, що використовується Map. Його можна підключити до іншої реалізації:
class PostgresOrderRepository {
async save(order) {
// Тут могла б бути вставка в PostgreSQL
return order;
}
async findById(id) {
// Тут міг би бути запит до PostgreSQL
return null;
}
}У реальному коді цей адаптер містив би SQL-запити або виклики ORM, але доменний код і CreateOrder залишилися б без змін.
У прикладі є два адаптери:
InMemoryOrderRepository — вихідний адаптер;
createHttpServer — вхідний адаптер.
HTTP-сервер працює з JSON, HTTP-статусами та заголовками. Це деталі транспорту, тому вони не належать до доменної моделі.
Одна з головних переваг гексагональної архітектури — можливість тестувати застосункову логіку без запуску:
HTTP-сервера;
бази даних;
зовнішніх сервісів;
черг повідомлень.
Для цього залежність замінюють простою тестовою реалізацією.
const assert = require('node:assert/strict');
class FakeOrderRepository {
constructor() {
this.savedOrders = [];
}
async save(order) {
this.savedOrders.push(order);
return order;
}
}
async function testCreateOrder() {
const repository = new FakeOrderRepository();
const createOrder = new CreateOrder({
orderRepository: repository,
idGenerator: () => 'order-1'
});
const order = await createOrder.execute({
customerId: 'customer-1',
items: [
{
productId: 'product-1',
quantity: 2
}
]
});
assert.equal(order.id, 'order-1');
assert.equal(order.status, 'pending');
assert.equal(repository.savedOrders.length, 1);
assert.equal(repository.savedOrders[0], order);
console.log('Тест успішно виконано');
}
testCreateOrder().catch((error) => {
console.error(error);
process.exitCode = 1;
});Тест перевіряє сценарій створення замовлення, але не залежить від HTTP або реальної бази даних.
Це робить тести:
швидшими;
стабільнішими;
простішими для діагностики;
придатними для запуску в ізоляції.
Вона особливо корисна, коли:
бізнес-логіка складна або критична;
застосунок має кілька способів взаємодії;
інфраструктура може змінюватися;
потрібні швидкі модульні тести;
зовнішні сервіси складно або дорого запускати під час тестування;
команда хоче явно відокремити правила предметної області від технічних деталей.
Наприклад, один сценарій оплати може викликатися:
HTTP API;
фоновим обробником черги;
адміністративною командою.
Усі ці способи можуть використовувати один вхідний порт і одну застосункову логіку.
Гексагональна архітектура не означає, що кожен клас потрібно перетворити на інтерфейс, а кожен виклик — обгорнути кількома шарами.
Для невеликого CRUD-застосунку з простою логікою додаткові порти й адаптери можуть створити більше коду, ніж користі.
Перед застосуванням підходу варто оцінити:
чи є в системі суттєва бізнес-логіка;
чи плануються зміни інфраструктури;
чи потрібні ізольовані тести;
чи існує кілька зовнішніх способів взаємодії;
чи справді залежність від конкретної технології заважає розвитку.
Архітектура має зменшувати складність, а не додавати формальності заради самої формальності.
Основне правило можна сформулювати так:
Внутрішній код визначає контракти, а зовнішній код їх реалізує.
Неправильний напрямок:
Домен -> PostgreSQL
Домен -> Express
Домен -> StripeПравильний напрямок:
PostgreSQL-адаптер -> порт репозиторію <- застосункова логіка
HTTP-адаптер -> вхідний порт <- застосункова логікаЗовнішні технології можуть змінюватися, але внутрішні правила системи не повинні залежати від них.
Для HTTP-запиту на створення замовлення потік може бути таким:
HTTP-адаптер отримує JSON.
HTTP-адаптер викликає вхідний порт CreateOrder.
Застосунковий сценарій створює доменну модель Order.
Доменна модель перевіряє власні правила.
Сценарій викликає вихідний порт OrderRepository.
Репозиторій-адаптер зберігає замовлення.
HTTP-адаптер формує відповідь клієнту.
HTTP і зберігання є деталями. Основною частиною потоку залишається правило створення коректного замовлення.
Якщо доменна модель імпортує HTTP-фреймворк, ORM або SDK зовнішнього сервісу, ізоляція порушена.
Краще передавати домену звичайні значення та об’єкти, а перетворення залишати адаптерам.
Контролер не повинен вирішувати, чи дозволено створювати замовлення. Він має лише прийняти запит, викликати сценарій і сформувати відповідь.
Інакше логіку буде складно повторно використати з іншого адаптера.
Порт має описувати потребу застосунку, а не копіювати методи конкретного ORM або SDK.
Невдалий порт:
repository.query(sql, parameters);Такий контракт прив’язує застосунок до SQL.
Краще описати операцію предметної області:
repository.save(order);Адаптер може перевірити формат JSON або наявність HTTP-параметра. Але правило на кшталт «замовлення має містити хоча б один товар» належить домену.
Не кожна функція потребує окремого порту. Порт має з’являтися там, де є реальна межа між внутрішньою логікою та зовнішньою залежністю.
Гексагональна архітектура відокремлює бізнес-логіку від інфраструктури.
Порти описують контракти взаємодії.
Адаптери реалізують ці контракти або перетворюють зовнішні дані.
Вхідні адаптери викликають застосункові сценарії.
Вихідні адаптери надають інфраструктурні залежності.
Залежності мають бути спрямовані до домену.
Інфраструктурні реалізації можна замінювати без зміни доменної логіки.
Фіктивні адаптери спрощують швидке й ізольоване тестування.
Для простих систем цей підхід може бути зайвим, тому його слід застосовувати там, де незалежність і тестованість справді мають цінність.