Driving и driven стороны
У гексагона две стороны: driving (левая) — акторы инициируют через HTTP, CLI, тесты; driven (правая) — приложение обращается наружу к БД, оплате, почте. Обе стороны говорят через порты, принадлежащие domain. Domain изолирован в центре.
На B2B-платформе заказов была рабочая hexagonal-структура для HTTP-канала: контроллер вызывал порт подтверждения, порт принадлежал domain, PostgreSQL-адаптер реализовывал порт репозитория с другой стороны. Затем команда добавила Stripe для обработки платежей. В первой реализации вызов Stripe SDK был помещён прямо внутри use case подтверждения заказа: await stripe.paymentIntents.create(...). Три месяца спустя переход на другой платёжный процессор для европейских клиентов потребовал изменения самого use case. Команда перенесла вызов Stripe в класс сервиса, но тот класс сервиса импортировал stripe на верхнем уровне — и domain-модуль приобрёл транзитивную зависимость от npm-пакета Stripe. Тест, импортирующий domain для проверки логики подтверждения заказа, подтягивал Stripe и требовал реального API-ключа или ручного мока. Команда правильно определила, что Stripe — внешняя забота. Но она неправильно расставила границы: domain всё ещё тянулся к infrastructure. Driven-сторона — правая сторона гексагона — не была инвертирована. Понять, на какой стороне находится платёжный шлюз и почему — это структурный урок данного раздела.
Две стороны гексагона
Оригинальное описание Cockburn размещало адаптеры на двух различных сторонах, и асимметрия имеет значение:
Driving-сторона (левая / primary) — акторы, инициирующие взаимодействие с приложением. Driving-адаптер вызывает primary port, чтобы заставить приложение что-то сделать. HTTP-контроллер получает POST и вызывает confirmOrder. CLI получает команду и вызывает confirmOrder. Автоматизированный тест вызывает confirmOrder. Каждый driving-адаптер переводит собственный формат входных данных в вызов метода порта.
Driven-сторона (правая / secondary) — инфраструктура, которую приложение активирует для выполнения своей работы. Driven-адаптер реализует secondary port. Приложение вызывает IOrderRepository.save(order) — Postgres-адаптер отвечает. Приложение вызывает IPaymentGateway.charge(amount) — Stripe-адаптер отвечает. Приложение вызывает IEmailNotifier.send(message) — SendGrid-адаптер отвечает.
Критическая асимметрия: driving-сторона вызывает приложение; driven-сторона вызывается приложением. Обе стороны опосредованы портами. Но направление инициирования противоположно.
Domain в центре
Domain находится в центре гексагона, окружённый с обеих сторон портами, которыми он владеет. Это структурное следствие применения Dependency Inversion Principle (unit 02) на архитектурном уровне: высокоуровневая политика (domain) владеет абстракциями; низкоуровневые механизмы (адаптеры) зависят от этих абстракций, чтобы подключиться.
На B2B-платформе domain определяет:
// Primary ports (что domain предлагает)
interface OrderConfirmationPort {
confirmOrder(orderId: string, approverId: string): Promise<ConfirmationResult>;
}
interface InvoiceGenerationPort {
generateInvoice(orderId: string): Promise<InvoiceId>;
}
// Secondary ports (что domain требует)
interface IOrderRepository {
findById(id: string): Promise<Order | null>;
save(order: Order): Promise<void>;
}
interface IPaymentGateway {
charge(customerId: CustomerId, amount: Money): Promise<ChargeResult>;
}
interface IEmailNotifier {
send(recipient: Email, subject: string, body: string): Promise<void>;
}Все эти интерфейсы живут в пакете domain. Ни один из них не импортирует HTTP, Stripe, Postgres или SendGrid. Domain не имеет никаких знаний о технологических выборах внешнего мира. Он знает только, что ему нужно (secondary ports) и что он предоставляет (primary ports).
HTTP, CLI и тест-драйвер — все вызывают один и тот же primary port
Одно из наиболее ценных свойств driving-стороны: она нормализует все точки входа к одному и тому же интерфейсу порта. Use case подтверждения определён один раз на primary port. Любая технология, способная сформировать необходимые доменные параметры, может его вызвать.
// HTTP driving adapter
class HttpOrderController {
constructor(private port: OrderConfirmationPort) {}
async post(req: Request): Promise<Response> {
const result = await this.port.confirmOrder(
req.params.orderId,
req.body.approverId
);
return result.success
? Response.json({ status: 'confirmed' }, { status: 200 })
: Response.json({ error: result.reason }, { status: 422 });
}
}
// CLI driving adapter
class CliOrderConfirmCommand {
constructor(private port: OrderConfirmationPort) {}
async run(args: string[]): Promise<void> {
const [orderId, approverId] = args;
const result = await this.port.confirmOrder(orderId, approverId);
console.log(result.success ? 'Confirmed' : `Failed: ${result.reason}`);
}
}
// Test driving adapter — фреймворк не нужен, только порт
async function confirmOrderViaPort(
port: OrderConfirmationPort,
orderId: string,
approverId: string
): Promise<ConfirmationResult> {
return port.confirmOrder(orderId, approverId);
}Логика подтверждения выполняется идентично независимо от того, является ли вызывающим HTTP-запрос, CLI-команда или тест. Тест-драйвер максимально прост: прямой вызов. Никакого HTTP-клиента, номера порта или запуска сервера. Это доступно именно потому, что primary port принадлежит domain и говорит на языке domain.
▸Почему это работает
Почему driving-сторона важна структурно, а не только как «несколько точек входа»? Потому что primary port — это формальное определение того, что умеет делать приложение — его публичный контракт. Всё, что предлагает приложение, должно быть выразимо через метод primary port. Это имеет два следствия: (1) предотвращает размещение логики только в адаптерах (HTTP-only функция без метода порта архитектурно невидима); (2) делает возможности приложения перечислимыми — можно посмотреть на определения primary port и увидеть каждый use case, не читая код адаптеров. Вот почему тесты, написанные против primary port, являются наиболее значимыми регрессионными тестами: они тестируют фактический контракт приложения, а не интерпретацию адаптером этого контракта.
Driven-сторона: secondary ports инвертируют зависимость от infrastructure
Driven-сторона — это место, где DIP из unit 02 становится конкретным. Domain определяет, что ему нужно — IPaymentGateway, IOrderRepository — в собственном пакете, на языке domain. Stripe-адаптер реализует IPaymentGateway; он зависит от определения интерфейса domain. Postgres-адаптер реализует IOrderRepository; он зависит от определения интерфейса domain.
Направление source-code зависимости на driven-стороне то же, что и на driving-стороне: всегда внутрь, к domain.
// Secondary port — определён в пакете domain
// domain/ports/IPaymentGateway.ts
interface IPaymentGateway {
charge(customerId: CustomerId, amount: Money): Promise<ChargeResult>;
refund(chargeId: ChargeId, amount: Money): Promise<RefundResult>;
}
// Driven adapter — определён в пакете infrastructure
// infra/payment/StripePaymentAdapter.ts
import { IPaymentGateway, ChargeResult, RefundResult } from '../../domain/ports/IPaymentGateway';
import Stripe from 'stripe';
class StripePaymentAdapter implements IPaymentGateway {
private client = new Stripe(process.env.STRIPE_KEY!);
async charge(customerId: CustomerId, amount: Money): Promise<ChargeResult> {
const intent = await this.client.paymentIntents.create({
amount: amount.inCents(),
currency: amount.currency,
customer: customerId.value,
});
return ChargeResult.fromStripeIntent(intent);
}
async refund(chargeId: ChargeId, amount: Money): Promise<RefundResult> {
const refund = await this.client.refunds.create({
payment_intent: chargeId.value,
amount: amount.inCents(),
});
return RefundResult.fromStripeRefund(refund);
}
}StripePaymentAdapter импортирует из domain (IPaymentGateway). Domain не импортирует из StripePaymentAdapter. npm-пакет stripe — деталь адаптера: domain не имеет транзитивной зависимости от него. Это исправление истории из вступления: перемещение вызова Stripe за secondary port означает, что domain можно тестировать без Stripe, а смена процессора платежей сводится к написанию нового адаптера без изменения domain.
▸lesson.inset.note
Терминология «driving/driven» напрямую соответствует «primary/secondary»: driving adapters вызывают primary ports, driven adapters реализуют secondary ports. Некоторые источники используют «левая/правая сторона», потому что в стандартной диаграмме driving adapters размещены слева, а driven adapters — справа. Все три пары (driving/driven, primary/secondary, левый/правый) описывают одно и то же структурное различие: какая сторона инициирует взаимодействие, а какая отвечает. Используйте ту терминологию, которую закрепит ваша команда — структурное отношение — это то, что важно.
Платформа заказов добавляет Kafka-потребителя, читающего события OrderPlaced из топика и инициирующего генерацию счёта. На какой стороне гексагона находится Kafka-потребитель и почему?
Разработчик предлагает: «Давайте определим IPaymentGateway в общем пакете infra/interfaces/, чтобы и domain, и Stripe-адаптер могли импортировать его, не завися друг от друга». Правильно ли это применяет DIP? Почему?
Команда пишет интеграционный тест, запускающий реальный HTTP-сервер, отправляющий реальный HTTP-запрос и проверяющий HTTP-ответ. Используется реальная PostgreSQL база данных. Является ли это тестом приложения через primary port? Что он тестирует, чего не может тест уровня primary port?
- 01Что отличает driving adapter от driven adapter — и как это соотносится с направлением runtime-вызовов против направления source-code зависимостей?
- 02Почему тест-драйвер структурно является driving adapter, и какую возможность тестирования это даёт, недоступную HTTP-уровневым тестам?
- 03На B2B-платформе Stripe SDK изначально вызывался напрямую внутри use case подтверждения заказа. Какое структурное правило это нарушало, и как перемещение за secondary port исправляет это?
У гексагона две стороны, и асимметрия структурна.
Driving-сторона (левая) содержит адаптеры, инициирующие взаимодействие с приложением. Каждая точка входа — HTTP-контроллер, CLI-раннер, Kafka-потребитель, автоматизированный тест — является driving adapter. Каждый переводит собственный формат входных данных в вызов primary port, принадлежащего domain. Приложение определяет, что умеет делать; driving adapters соответствуют этому определению.
Driven-сторона (правая) содержит адаптеры, отвечающие когда приложению нужны внешние возможности. Каждая исходящая зависимость — PostgreSQL, Stripe, SendGrid, S3 — находится за driven adapter. Domain определяет, что ему нужно, через secondary ports, которыми он владеет; driven adapters реализуют эти порты и поэтому зависят внутрь к domain.
Domain находится в центре, окружённый портами, которыми владеет с обеих сторон. Он ничего не знает об HTTP, базах данных, платёжных SDK или очередях сообщений. Он знает только собственные интерфейсы. Это DIP из unit 02, применённый архитектурно: domain — высокоуровневая политика, владеющая абстракциями; адаптеры — низкоуровневые механизмы, зависящие от этих абстракций.
Тест-драйвер является driving adapter — структурно идентичным HTTP-контроллеру. Он вызывает primary port напрямую, минуя HTTP полностью. Это то, что делает возможными быстрые, не требующие инфраструктуры domain-тесты: domain доступен через тот же интерфейс порта, который используют production-адаптеры, с фейковыми driven adapters, подставленными вместо реальной infrastructure с другой стороны.
Практика
Начни сверху. Задачи идут от простого к сложному: вспомнить факт, применить к случаю, затем senior-уровень. Открой, попробуй, потом открой ответ.
Что-то непонятно?
Задай вопрос по этому уроку. Вопросы анонимны и попадают напрямую автору — урок станет лучше.