Гексагональная (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 errors —
OrderNotFoundError,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)
Multi-agent: координатор и специалисты
Архитектура из координатора и специализированных агентов: передача контекста, дедупликация, race conditions.
Новый subagent или новый skill: что выбрать
Decision tree: создавать ли отдельного агента или достаточно skill. Критерии — контекст, переиспользование, frequency, complexity.
Architecture Decision Record (ADR)
Зафиксировать архитектурное решение: контекст, варианты, выбор и trade-offs.