Слои, гексагон и Clean: стрелки внутрь
Три самых известных способа организовать код сервиса на самом деле одна идея с разной упаковкой: бизнес-логика не должна знать про инфраструктуру. В этом уроке проходим эволюцию (слои, потом порты и адаптеры, потом Clean) на одной и той же фиче и формулируем единственное правило, которое стоит запомнить навсегда: стрелки зависимостей показывают внутрь.
Сквозная фича на весь урок: создание заказа. Правила: у пользователя есть лимит активных заказов, при успехе уходит письмо. Достаточно простая, чтобы поместиться в голове, достаточно настоящая, чтобы увидеть все болевые точки.
Layered (N-Tier): кладём код этажами
Первый ответ индустрии на толстый контроллер из входного урока: растянуть его вертикально, в этажи, где каждый слой знает только про соседний снизу.
Presentation (HTTP: роуты, парсинг, статусы)
│
▼
Application (use-cases: сценарий "создать заказ")
│
▼
Domain (правила: лимит заказов, статусы заказа)
│
▼
Infrastructure (Postgres, почта, внешние API)
Наша фича в этой укладке:
// presentation/orders.controller.ts
app.post('/orders', async (req, reply) => {
const result = await createOrder(req.user.id, parseBody(req)); // вниз, в application
return reply.code(201).send(result);
});
// application/create-order.ts
async function createOrder(userId: string, input: NewOrderInput) {
const active = await ordersRepo.countActive(userId); // вниз, в infrastructure
if (active >= ORDER_LIMIT) throw new OrderLimitReached(); // правило из domain
const order = await ordersRepo.insert({ userId, ...input });
await mailer.sendOrderCreated(userId, order.id); // вниз, в infrastructure
return order;
}
Это уже большой шаг вперёд: HTTP отделён от логики, SQL собран в одном месте, сценарий читается сверху вниз. Для массы приложений этого достаточно, и дальше можно не ходить. Но у этажей есть две болезни, которые проявляются с ростом.
Первая: anemic domain model. Слой Domain постепенно вырождается в папку с типами и DTO, а все правила утекают в Application-сервисы. Получается процедурный код, разложенный по красивым папкам: данные отдельно, поведение отдельно, инварианты размазаны по десятку сервисов.
Вторая болезнь важнее: посмотри на стрелки. Domain стоит на Infrastructure. Слой с самым ценным и стабильным кодом (бизнес-правила) зависит от слоя с самым изменчивым (драйверы, ORM, SDK почтовой службы). Поменяли ORM: пересобираем домен. Захотели протестировать правило лимита: поднимаем Postgres. Знакомая боль из урока про functional core, только теперь видно её архитектурную причину: стрелка смотрит не в ту сторону.
Hexagonal (Ports & Adapters): переворачиваем стрелку
Алистер Кокберн предложил смотреть не этажами, а центром и краями: в центре приложение с логикой, по краям мир, и между ними явные контракты.
driving (кто зовёт нас) driven (кого зовём мы)
HTTP ──┐ ┌── Postgres
CLI ──┤--> [port] ┌───────────┐ [port]-├── SMTP
queue ─┘ │ ядро │ └── внешний API
│ use-cases │
│ правила │
└───────────┘
адаптеры ^ адаптеры
ядро не знает их имён
Два слова, которые нужно усвоить:
- Порт это интерфейс, объявленный ядром. Driving-порты: что у ядра можно попросить («создай заказ»). Driven-порты: что ядру нужно от мира («умей сохранить заказ», «умей отправить письмо»).
- Адаптер это перевод между портом и конкретной технологией: HTTP-контроллер, Postgres-репозиторий, SMTP-клиент.
Ключевой трюк в том, кто объявляет интерфейс. Driven-порт объявлен внутри ядра, а реализован снаружи:
// core/ports.ts: ядро ДИКТУЕТ, что ему нужно от мира
export interface OrderStore {
countActive(userId: string): Promise<number>;
insert(order: NewOrder): Promise<Order>;
}
export interface Notifier {
orderCreated(userId: string, orderId: string): Promise<void>;
}
// core/create-order.ts: логика знает только порты
export function makeCreateOrder(store: OrderStore, notifier: Notifier) {
return async (userId: string, input: NewOrderInput) => {
const active = await store.countActive(userId);
if (active >= ORDER_LIMIT) return { ok: false as const, reason: 'limit' as const };
const order = await store.insert({ userId, ...input });
await notifier.orderCreated(userId, order.id);
return { ok: true as const, order };
};
}
// adapters/postgres-order-store.ts: снаружи, реализует контракт ядра
export const postgresOrderStore: OrderStore = {
countActive: (userId) => /* SQL */,
insert: (order) => /* SQL */,
};
// adapters/in-memory-order-store.ts: второй адаптер, для тестов
export const inMemoryOrderStore = (): OrderStore => {
const orders: Order[] = [];
return {
countActive: async (userId) => orders.filter((o) => o.userId === userId && o.active).length,
insert: async (order) => { /* push */ },
};
};
Стрелка перевернулась: раньше ядро импортировало Postgres-клиент, теперь Postgres-адаптер импортирует интерфейс ядра. Это инверсия зависимостей в чистом виде, и именно она, а не шестиугольник на диаграмме, составляет суть паттерна.
Проделай переворот руками и следи за счётчиком: сколько модулей трогает смена базы до выворота стрелки и сколько после.
Это классический layered: домен стоит на инфраструктуре. Поменяй базу, и пересборка с ретестом катится вверх по всем стрелкам.
Красная пунктирная стрелка нарушает правило: зависимость смотрит наружу, из ядра в технологию. Кнопка «вывернуть» объявляет интерфейс-порт внутри Domain и превращает технологию в адаптер. Когда нарушений ноль, перед тобой гексагон; Clean добавит к этому только лишнее кольцо внутри ядра.
Тест на качество гексагона: смена технологии стоит один адаптер. Переезд с Postgres на другую БД не трогает ядро. Тест use-case работает с in-memory адаптером без контейнеров. Netflix в известном посте про гексагональную архитектуру описывал ровно это: источник данных у них менялся за пару часов, потому что менялся только адаптер.
И заметь: driving-сторона симметрична. HTTP-роут, CLI-команда и обработчик очереди это три driving-адаптера над одним ядром. Живой пример у тебя перед глазами: data-слой этого сайта написан Effect-функциями, которые одинаково зовутся из Astro-роутов и из admin-CLI. Два входа, одно ядро, ни одна бизнес-функция не знает, кто её позвал.
дальше после входа
Урок целиком открыт всем, кто вошёл. Войди по своему email, чтобы читать дальше.
Вход пока по приглашению. Если хочешь учиться, напиши Vassiliy.