Порты и адаптеры
Гексагональная архитектура масштабирует DIP на целые системы — домен владеет портами (нужными ему интерфейсами), инфраструктура даёт адаптеры, и каждая зависимость направлена внутрь. Домен — стабильный центр; ввод-вывод заменяем на краю. Структура должна окупать свою цену.
Ты открываешь модуль обработки заказов, и первая же строка — import Stripe from "stripe". Правила ценообразования, логика возвратов, инвариант «оплаченный заказ отгружается за 24 часа» — всё это теперь транзитивно зависит от SDK платёжного вендора. Ты не можешь запустить домен в юнит-тесте без сети и API-ключа. Ты не можешь заменить Stripe на Adyen, не трогая правила. Бизнес-логика, та часть, что на самом деле твоя, приварена к детали, которую ты не контролируешь.
Этот сварной шов и есть проблема, которую решают порты и адаптеры. Принцип инверсии зависимостей сказал «завись от абстракций»; гексагональная архитектура задаёт тот же вопрос на масштабе целой системы: в какую сторону должны указывать стрелки? Ответ — всегда внутрь, к домену — и есть то, что держит стабильный центр стабильным, пока ввод-вывод на краю остаётся заменяемым.
После этого урока ты можешь объяснить гексагональную архитектуру как DIP, применённый на масштабе архитектуры: домен объявляет порты (нужные ему интерфейсы), инфраструктура поставляет адаптеры (реализации), и все зависимости указывают внутрь, так что домен ничего не знает ни про Stripe, ни про Postgres, ни про HTTP. Ты можешь показать, как in-memory-адаптер делает домен тестируемым без ввода-вывода, и — senior-половина — назвать режим отказа: навязывание полной гексагональной церемонии CRUD-приложению, у которого нет настоящего домена, где слоистость стоит больше, чем когда-либо вернёт.
Порт — это интерфейс, которым владеет домен; домен объявляет, что ему нужно, но никогда — что это предоставляет. Домен говорит «чтобы делать свою работу, мне нужно списать платёж» — и пишет интерфейс ровно под это, на языке домена, в коде домена:
// domain/ports/PaymentPort.ts — принадлежит домену, называет потребность домена
export interface PaymentPort {
charge(amountCents: number, idempotencyKey: string): Promise<ChargeResult>;
}
export type ChargeResult = { ok: true; receiptId: string } | { ok: false; reason: string };Заметь, чего здесь нет: ни Stripe, ни HTTP, ни API-ключа, ни типа из SDK. Порт говорит на словаре домена (amountCents, idempotencyKey, ChargeResult), а не вендора. Это и есть инверсия: высокоуровневая политика (домен) определяет абстракцию, а низкоуровневая деталь (Stripe) будет вынуждена подчиниться ей — а не наоборот. Порт — это контракт, от которого домен готов зависеть.
Адаптер — это реализация, живущая в инфраструктуре; он реализует порт домена и поглощает вендора. Существование Stripe теперь — факт, запертый в одном файле на краю:
// infrastructure/StripeAdapter.ts — реализует порт ДОМЕНА
import Stripe from "stripe";
import type { PaymentPort, ChargeResult } from "../domain/ports/PaymentPort";
export class StripeAdapter implements PaymentPort {
constructor(private stripe: Stripe) {}
async charge(amountCents: number, idempotencyKey: string): Promise<ChargeResult> {
try {
const intent = await this.stripe.paymentIntents.create(
{ amount: amountCents, currency: "usd", confirm: true },
{ idempotencyKey },
);
return { ok: true, receiptId: intent.id };
} catch (e) {
return { ok: false, reason: stripeReason(e) }; // перевод ошибок вендора в термины домена
}
}
}Вся работа адаптера — перевод: на входе язык домена, на выходе вызовы вендора, ошибки вендора отображаются обратно в ChargeResult. Домен зависит от PaymentPort; StripeAdapter тоже зависит от PaymentPort (он его реализует) и от Stripe. Стрелка из инфраструктуры указывает внутрь, в домен. Stripe теперь лист, а не корень — заменяем написанием второго адаптера, и в домене не нужно трогать ничего.
Домен использует порт и остаётся в неведении о реализации — зависимость внедряется на краю. Сценарий использования написан целиком против порта:
// domain/PlaceOrder.ts — чистая политика, ноль знаний о вендоре
export class PlaceOrder {
constructor(private payments: PaymentPort) {} // зависит от порта, не от Stripe
async run(order: Order): Promise<OrderResult> {
if (order.totalCents <= 0) return { status: "rejected", reason: "empty order" };
const charge = await this.payments.charge(order.totalCents, order.id);
if (!charge.ok) return { status: "declined", reason: charge.reason };
return { status: "placed", receiptId: charge.receiptId };
}
}PlaceOrder — это стабильный центр. Его можно читать, осмыслять и менять, ни разу не открыв доки Stripe. Сборка — «в проде PaymentPort — это StripeAdapter» — происходит один раз, в корне композиции на самом внешнем краю:
// main.ts — единственное место, знающее обе стороны
const placeOrder = new PlaceOrder(new StripeAdapter(stripe));Эта единственная строка — там, где абстрактное встречается с конкретным. Всё, что внутрь от неё, не зависит от вендора по построению.
Отдача конкретна: in-memory-адаптер делает домен тестируемым без сети, без ключей, без флакающих тестов. Поскольку домен зависит только от PaymentPort, подходит любая реализация — включая фейк, записывающий вызовы в список:
// test/InMemoryPayment.ts — второй адаптер, для тестов
class InMemoryPayment implements PaymentPort {
public charged: { amountCents: number; key: string }[] = [];
constructor(private decline = false) {}
async charge(amountCents: number, key: string): Promise<ChargeResult> {
this.charged.push({ amountCents, key });
return this.decline
? { ok: false, reason: "card_declined" }
: { ok: true, receiptId: "rcpt_test" };
}
}
test("declined payment yields a declined order", async () => {
const payments = new InMemoryPayment(true);
const result = await new PlaceOrder(payments).run(makeOrder({ totalCents: 5000 }));
expect(result.status).toBe("declined");
expect(payments.charged).toHaveLength(1); // мы проверили побочный эффект без сети
});Эти тесты выполняются за миллисекунды, детерминированно, без секретов. Тот же шов, что в проде позволяет заменить Stripe на Adyen, в тестах позволяет заменить его на фейк — тестовые дублёры — это просто ещё один адаптер. Это повседневный дивиденд от направления зависимостей внутрь, и это самое честное доказательство того, что граница реальна: если бы домен был приварен к Stripe, такого теста не могло бы существовать.
От приваренного к инвертированному. Фича возврата, написанная очевидным образом, тянется прямо к SDK изнутри бизнес-правила:
// ДО — правило домена импортирует вендора
import Stripe from "stripe";
export async function refundOrder(order: Order, stripe: Stripe) {
if (order.status !== "placed") throw new Error("only placed orders refund");
if (daysSince(order.paidAt) > 30) throw new Error("refund window closed"); // настоящее правило домена
await stripe.refunds.create({ payment_intent: order.receiptId }); // вендор вшит в политику
order.status = "refunded";
}Окно в 30 дней — это подлинное доменное знание, которое стоит защищать, но теперь оно недостижимо без экземпляра Stripe. Ты не можешь протестировать правило без SDK; ты не можешь сменить вендора, не правя правило. Инвертируй это — домен объявляет RefundPort, инфраструктура его реализует:
// ПОСЛЕ — домен владеет портом, вендор живёт на краю
export interface RefundPort {
refund(receiptId: string): Promise<void>;
}
export async function refundOrder(order: Order, refunds: RefundPort) {
if (order.status !== "placed") throw new Error("only placed orders refund");
if (daysSince(order.paidAt) > 30) throw new Error("refund window closed");
await refunds.refund(order.receiptId); // зависит от порта, не от Stripe
order.status = "refunded";
}
// infrastructure/StripeRefundAdapter.ts
export class StripeRefundAdapter implements RefundPort {
constructor(private stripe: Stripe) {}
refund = (receiptId: string) =>
this.stripe.refunds.create({ payment_intent: receiptId }).then(() => {});
}Теперь правило 30 дней тестируется однострочным фейком RefundPort, вендор — один адаптер на краю, а политика читается без всякого шума Stripe. Ничего хитрого не произошло — стрелку зависимости просто развернули так, чтобы она указывала на то, чем владеет домен.
▸Почему это работает
Почему именно домен должен владеть портом, а не инфраструктура — экспортировать интерфейс, который домен импортирует? Потому что владение — это то, что делает инверсию настоящей. Если бы инфраструктура определила PaymentGateway, а домен его импортировал, домен всё равно зависел бы от модуля, существующего ради обёртки вендора — стрелка указывала бы наружу, и интерфейс дрейфовал бы к форме вендора (в нём проступали бы currency, paymentMethodId, поля в стиле Stripe). Когда порт объявляет домен, абстракцию формируют потребности домена, вендор вынужден подчиниться, а направление зависимости гарантировано структурно: ничто в папке домена не импортирует ничего из инфраструктуры. В этом всё содержание правила «зависимости указывают внутрь» — это правило о том, какой пакет какой импортирует, проверяемое границей в линтере, а не на ощущениях.
▸Частая ошибка
Senior-режим отказа — навязывание гексагональной церемонии приложению, у которого нет домена. CRUD-админка — прочитать строку, проверить три поля, записать обратно — не имеет стабильной политики в центре; это ввод-вывод почти от края до края. Обернув её в порты, адаптеры, корень композиции и слой маппинга на каждую таблицу, ты ничего не получишь для защиты (нет бизнес-правила, которое должно оставаться независимым от вендора), но обложишь каждую фичу тремя лишними файлами и шагом перевода. Структура должна окупать свою цену: порты и адаптеры окупаются ровно тогда, когда есть настоящий домен — правила и инварианты с реальной ценностью, — который ты хочешь держать стабильным и тестируемым, пока ввод-вывод вокруг меняется. Если смена базы данных меняла бы твои бизнес-правила — у тебя ещё нет домена, который надо защищать; тянись к границе, когда домен появится, а не раньше. Преждевременный гексагон — это ровно такое же переусложнение, как и полное отсутствие границы.
В гексагональной архитектуре где живёт интерфейс PaymentPort и почему это размещение важно?
Порты и адаптеры (гексагональная архитектура) — это принцип инверсии зависимостей на масштабе архитектуры: домен объявляет порты — интерфейсы на своём собственном словаре для того, что ему нужно, — а инфраструктура поставляет адаптеры, которые их реализуют, так что каждая стрелка зависимости указывает внутрь, к домену. Домен становится стабильным центром, который ничего не знает ни про Stripe, ни про Postgres, ни про HTTP; ввод-вывод — заменяемая деталь на краю, привязанная один раз в корне композиции. Повседневный дивиденд — тестируемость: тестовый дублёр — это просто ещё один адаптер, поэтому домен работает без сети и секретов. Но структура должна окупать свою цену — навязывание полной церемонии CRUD-приложению без настоящего домена не даёт стабильности для защиты, зато облагает налогом каждую фичу. Тянись к границе, когда есть настоящий домен, который стоит держать стабильным; не раньше.
Практика
Начни сверху. Задачи идут от простого к сложному: вспомнить факт, применить к случаю, затем senior-уровень. Открой, попробуй, потом открой ответ.
Что-то непонятно?
Задай вопрос по этому уроку. Вопросы анонимны и попадают напрямую автору — урок станет лучше.