Skip to content
PПромтбук
RUEN
04Архитектура

Гексагональная (Clean) архитектура: порты и адаптеры

Dependency rule, что в core и что в infra, ports как интерфейсы, adapters как реализации — без overengineering.

Разложи {{service}} по гексагональной (ports & adapters) / clean архитектуре. Стек: {{stack}}.

Базовая аксиома: clean architecture — это не «папки domain/application/infrastructure». Это dependency rule: core не знает о infra. Если можно удалить Postgres и переключиться на in-memory без правки core — у тебя hexagonal. Если нельзя — нет.

1. Зачем вообще

Что даёт:

  • Тестируемость: core тестируется без БД, HTTP, queue — миллисекунды на тест
  • Сменяемость: Postgres → DynamoDB без переписывания бизнес-логики
  • Понятность: входы и выходы домена явные, не размазаны по слоям
  • Долговечность: фреймворк/БД устаревают, бизнес-правила нет

Цена:

  • Больше интерфейсов и DI
  • Маппинг между слоями (domain ↔ persistence model)
  • Кривая обучения для команды

Когда не делать:

  • Скрипт на 200 строк
  • CRUD-обёртка над БД без бизнес-правил
  • Прототип на неделю

Когда делать:

  • Долгоживущий сервис с реальным доменом (правила, инварианты)
  • Нужно менять инфру (миграции БД, переход на другой провайдер)
  • Команда > 1 разработчика, важна тестируемость

2. Dependency rule

Главное правило одно:

infra → application → domain

Стрелка = «знает о». Domain ничего не знает. Application знает domain. Infra знает application и domain. Никогда наоборот.

Проверка: импорты в domain — только из domain (и stdlib). Если в domain/order.ts импорт из pg или express — нарушение.

3. Что в каждом слое

Domain (core, ядро)

  • Entities — объекты с identity (Order, User). Содержат инварианты
  • Value objects — immutable, без identity (Money, Email, OrderId)
  • Domain services — операции которые не принадлежат одному entity (PricingPolicy)
  • Domain events — факты домена (OrderPlaced)
  • Errors — доменные ошибки (InsufficientFundsError)

Не содержит: SQL, HTTP, JSON, log, время (new Date() — нет, инжектируй Clock).

Application (use cases)

  • Use cases / interactors — оркестрация для одного юзкейса (PlaceOrderUseCase)
  • Ports — интерфейсы которые core хочет вызывать (OrderRepository, PaymentGateway, Clock, EventPublisher)
  • DTO — input/output для use case (не путать с domain entities)
  • Application errorsOrderNotFoundError, UnauthorizedError

Не содержит реализации портов. Только интерфейсы и use case логику.

Infra (adapters)

  • Driving adapters (left side, входные) — HTTP controller, CLI, GraphQL resolver, event consumer. Вызывают use case
  • Driven adapters (right side, исходящие) — реализации портов: PostgresOrderRepository, StripePaymentGateway, SystemClock
  • Framework wiring — DI container, server bootstrap, env config
  • Migrations, ORM mapping, HTTP routes

4. Ports — это интерфейсы core'а

Ports описывают что core хочет получить, не что инфра умеет. Это критично.

Плохо (port отражает БД):

interface OrderRepository {
  executeQuery(sql: string): Promise<Row[]>;
}

Хорошо (port отражает домен):

interface OrderRepository {
  findById(id: OrderId): Promise<Order | null>;
  save(order: Order): Promise<void>;
  findPendingForUser(userId: UserId): Promise<Order[]>;
}

Эвристика: если порт можно перенести в Mongo / DynamoDB / in-memory без изменения сигнатур — он правильный.

5. Адаптеры — реализации портов

Каждый порт имеет ≥1 реализацию:

  • Production: PostgresOrderRepository (использует SQL)
  • Test: InMemoryOrderRepository (Map в памяти)
  • Возможно: RedisOrderRepository (если кеш)
class PostgresOrderRepository implements OrderRepository {
  constructor(private db: Pool) {}
  async findById(id: OrderId): Promise<Order | null> {
    const row = await this.db.query('SELECT * FROM orders WHERE id = $1', [id.value]);
    return row ? this.mapToDomain(row) : null;
  }
  private mapToDomain(row: Row): Order { /* mapping */ }
}

Mapping между row и Order — в адаптере, не в Order. Order не знает что существует SQL.

6. Use case — оркестрация

class PlaceOrderUseCase {
  constructor(
    private orders: OrderRepository,
    private inventory: InventoryService,
    private payments: PaymentGateway,
    private events: EventPublisher,
    private clock: Clock,
  ) {}

  async execute(input: PlaceOrderInput): Promise<PlaceOrderOutput> {
    const items = await this.inventory.reserve(input.items);
    const order = Order.place(input.userId, items, this.clock.now());
    await this.payments.charge(input.userId, order.total);
    await this.orders.save(order);
    await this.events.publish(new OrderPlaced(order.id));
    return { orderId: order.id };
  }
}

Заметь: use case не знает о Postgres, Stripe, Kafka. Только об интерфейсах. Тест:

test('places order', async () => {
  const useCase = new PlaceOrderUseCase(
    new InMemoryOrderRepository(),
    new FakeInventory(),
    new FakePayments(),
    new InMemoryEventPublisher(),
    new FixedClock('2026-05-17T12:00:00Z'),
  );
  const result = await useCase.execute({ userId, items });
  expect(result.orderId).toBeDefined();
});

Тест в памяти, миллисекунды. Никакой Postgres.

7. Структура папок (один из вариантов)

src/
  domain/
    order/
      order.ts            # entity
      order-id.ts         # value object
      order-placed.ts     # domain event
      pricing-policy.ts   # domain service
  application/
    place-order/
      place-order.use-case.ts
      place-order.input.ts
    ports/
      order.repository.ts
      payment.gateway.ts
      clock.ts
      event.publisher.ts
  infra/
    persistence/
      postgres-order.repository.ts
      mappings/
        order.mapper.ts
    payment/
      stripe-payment.gateway.ts
    http/
      controllers/
        orders.controller.ts
    config/
      env.ts
    bootstrap.ts          # DI wiring
  test/
    fakes/
      in-memory-order.repository.ts
      fake-payment.gateway.ts

Альтернатива — feature-based (по bounded context):

src/
  orders/
    domain/
    application/
    infra/
  inventory/
    domain/
    application/
    infra/

Для микро-сервиса первая структура; для модульного монолита — вторая.

8. Driving vs driven adapter

  • Driving (вход): HTTP request → controller → use case. Controller — тонкий слой: парсит request, вызывает use case, маппит output в response
  • Driven (выход): use case → port → adapter → external (DB, API, queue)

Controller тоже адаптер. Если в use case ты импортируешь Request из express — нарушение. Use case принимает DTO, controller его собирает.

9. Когда хватит интерфейсов

Не на каждый класс — порт. Эвристика:

  • Боундари с внешним миром — всегда порт (DB, HTTP клиент, queue, file system, clock, random)
  • Внутри одного слоя — обычные классы / функции. Не abstract'ируй просто так

«Porting everything» — overengineering. Цель — не интерфейсы ради интерфейсов, а изоляция core от инфры.

10. DI / wiring

В bootstrap.ts собираешь граф зависимостей:

const db = new Pool(env.DATABASE_URL);
const orderRepo = new PostgresOrderRepository(db);
const payments = new StripePaymentGateway(env.STRIPE_KEY);
const placeOrder = new PlaceOrderUseCase(orderRepo, ..., payments, ...);
const app = createHttpApp({ placeOrder });

DI-контейнер (tsyringe, inversify, Spring, NestJS) — опционально. На малых сервисах ручная сборка читаемее.

11. Тестовая пирамида

  • Domain tests: pure unit, проверяют инварианты Order. Без mock'ов вообще
  • Use case tests: in-memory fakes для портов. Проверяют оркестрацию
  • Adapter tests: интеграционные, с реальной БД (testcontainers). Проверяют mapping и SQL
  • E2E: малое число, основные сценарии. HTTP → DB

Соотношение: domain >> use case >> adapter >> e2e.

Анти-паттерны

  • new Date() в domain — внедри Clock, иначе тесты flaky
  • ❌ Импорт pg / express / stripe в domain или application — нарушение dependency rule
  • ❌ Port отражает БД (executeQuery) — это leak инфры в core
  • ❌ Entity знает о persistence (order.toRow(), аннотации ORM на entity) — связали с ORM
  • ❌ Use case вызывает другой use case (через DI) — приведёт к транзитивным циклам; либо domain service, либо извлеки общий код
  • ❌ «Anemic domain» — entity с геттерами/сеттерами без поведения, вся логика в use case. Логика принадлежит entity если касается её инвариантов
  • ❌ Mapping в обе стороны через generic «toJson» — теряешь типизацию; пиши явные mapper'ы
  • ❌ Один OrderRepository на 30 методов — split по use case или bounded context
  • ❌ DI-контейнер с magic — debugging боль; явная композиция лучше
  • ❌ Hexagonal на CRUD без бизнес-логики — overengineering, обычный repository достаточно
  • ❌ Mock'ать domain entity в тестах — entity и есть subject under test, не mock'ай его

В конце

  • Карта: что в domain, application, infra
  • Список портов с обоснованием (почему именно этот контракт)
  • Адаптеры: prod + test fake для каждого порта
  • Use case'ы с DTO
  • Структура папок и conventions для команды
  • Тестовая стратегия по слоям
  • Запрет на импорты infra → domain (lint rule, dependency-cruiser)
К подразделу «Архитектура»
Похожие промты