open atlas
↑ К треку
Архитектурные паттерны ARCH · 04 · 02

Driving и driven стороны

У гексагона две стороны: driving (левая) — акторы инициируют через HTTP, CLI, тесты; driven (правая) — приложение обращается наружу к БД, оплате, почте. Обе стороны говорят через порты, принадлежащие domain. Domain изолирован в центре.

ARCH Middle ◷ 22 min
Уровень
ОсновыJuniorMiddleSenior

На 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?

Вспомните перед уходом
  1. 01
    Что отличает driving adapter от driven adapter — и как это соотносится с направлением runtime-вызовов против направления source-code зависимостей?
  2. 02
    Почему тест-драйвер структурно является driving adapter, и какую возможность тестирования это даёт, недоступную HTTP-уровневым тестам?
  3. 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-уровень. Открой, попробуй, потом открой ответ.

вспомнитьприменитьуглубить0 из 4 завершено

Что-то непонятно?

Задай вопрос по этому уроку. Вопросы анонимны и попадают напрямую автору — урок станет лучше.

хоткеи развернуть
поиск
K
пред. пьеса
k
след. пьеса
j
тиры
t
это меню
?
sources3
expand
  1. 01
  2. 02
  3. 03

Trademarks belong to their respective owners. Editorial reference only.