Hexagonal Architecture (Ports & Adapters) by Alistair Cockburn (2005). Domain в центре, adapters снаружи. Driving adapters (REST/CLI/Kafka/gRPC/Cron) -> driving ports -> application services -> domain entities. Services также вызывают driven ports (OrderRepository, PaymentGateway, EmailNotifier, EventPublisher) которые реализуются driven adapters снаружи (PostgresOrderRepo, DynamoOrderRepo (alt), InMemoryOrderRepo (test), StripeAdapter, SmtpEmailAdapter, KafkaEventPublisher). Dependency inversion: hexagon определяет interfaces, infrastructure реализует. 3 scenarios: REST->hexagon->Postgres canonical happy path, swap Postgres->DynamoDB без изменений в hexagon, test domain в isolation через in-memory adapters. ADRs: hexagonal vs layered 3-tier when to apply, mapping DTO<->domain entity boilerplate trade-offs.
Hexagonal Architecture (Alistair Cockburn, 2005) — первая формулировка идеи "изоляция бизнес-логики через интерфейсы". Clean (2012) и Onion (2008) — её прямые наследники. Метафора простая: бизнес-логика — это шестиугольник, грани — порты (interfaces), снаружи к ним подключаются адаптеры (concrete implementations). Adapter переводит с языка внешнего мира (HTTP, SQL, Kafka) на язык порта.
Почему это нужно — три причины: (1) сменить технологию (Postgres → DynamoDB, REST → gRPC) без переписывания ядра; (2) добавить новый driving channel (был REST, появился Kafka consumer) — use case не дублируется; (3) тестировать core без I/O — in-memory adapter вместо testcontainers, миллисекундные unit-тесты вместо минутных integration.
Главное правило: dependency inversion. В layered (3-tier) repository знает про Postgres и импортирует pg. В hexagonal core определяет интерфейс OrderRepository, а PostgresOrderRepo снаружи его реализует. Стрелка зависимости перевёрнута: infrastructure depends on core, core depends on nothing.
Hexagon — это коробка с двумя видами розеток. Driving ports (in) — это API, который ядро экспортирует наружу: "вот use cases, которые я умею делать". Driven ports (out) — это разъёмы, в которые ядро втыкает кабели: "мне нужен repository, payment gateway, email sender — реализуйте их кто-нибудь".
Adapter — это переходник. REST controller — driving adapter: получает HTTP, маппит JSON в command, дёргает driving port. PostgresOrderRepo — driven adapter: получает domain Order от core, маппит в SQL row, делает INSERT. Core не знает ни про HTTP, ни про SQL — только про порты.
Симметрия: и driving, и driven выглядят одинаково — interface + implementation. Поэтому test trivially: подменил все driven adapters на in-memory, дёрнул driving port напрямую — core работает изолированно, никаких mocks framework.
Три большие группы вертикально:
CreateOrderUseCase, CancelOrderUseCase, GetOrderQuery (interfaces).CreateOrderService, CancelOrderService, GetOrderService (implementations).Order entity, Money VO, Customer entity, DiscountPolicy (entities, value objects, business rules).OrderRepository, PaymentGateway, EmailNotifier, EventPublisher (interfaces).PostgresOrderRepo, DynamoOrderRepo (alt), InMemoryOrderRepo (для tests), StripeAdapter, SmtpEmailAdapter, KafkaEventPublisher.Ключевая визуальная подсказка: edges от driven ports к driven adapters — это DI binding. В prod к OrderRepository привязан PostgresOrderRepo. В test — InMemoryOrderRepo. Hexagon при этом тот же самый код.
Канонический happy path: HTTP POST /orders → REST controller валидирует DTO → маппит в CreateOrderCommand → вызывает driving port CreateOrderUseCase → DI резолвит в CreateOrderService → service оркестрирует domain (Order.create, Money.fromCents, DiscountPolicy.apply) → service вызывает driven ports (repo.save, payment.charge, email.send, events.publish) → adapters реализуют каждый порт → 201 Created.
Один HTTP request бьёт по 5 driven ports. Каждый порт — interface; реализация снаружи. Hexagon (entities + services + ports) не импортирует ни express, ни pg, ни stripe, ни kafkajs — это и есть dependency inversion.
Миграция Postgres → DynamoDB без изменений в hexagon. Бизнес-причина: global tables для multi-region, managed write throughput. План: (1) написать DynamoOrderRepo implements OrderRepository — новый файл с маппингом domain Order → DynamoDB Item shape; (2) поменять DI binding (bind(OrderRepository).to(DynamoOrderRepo)); (3) задеплоить — тот же jar/binary, изменился только injection.
Сравните с layered 3-tier: там OrderRepository напрямую держит pg.Pool field. Swap = переписать Repository + найти все места, где QueryBuilder утёк в Service. В hexagonal interface не содержит ничего Postgres-specific — если утечка была, компилятор поймал бы при написании DynamoOrderRepo.
Реальная миграция обычно идёт через dual-write phase: bind to обе реализации (write Postgres + Dynamo, read Postgres) → backfill старых orders → switch read to Dynamo → remove Postgres write → удалить pg-adapter. Кода в core = 0 строк изменений. В layered — 200-500.
Test domain в isolation: InMemoryOrderRepo (просто Map), FakePaymentGateway (всегда success), CapturingEventPublisher (массив для assertions). Wiring: new CreateOrderService(repo, pay, fakeEmail, events). Дёргаешь svc.execute({...}) — реальная domain логика выполняется, DiscountPolicy.apply() считает реальный discount, Order.create() проверяет реальные invariants. Без mocks framework, без testcontainers. Весь test ~5ms, 1000 тестов — 5 секунд.
Edge cases тестируются так же тривиально: expired coupon → DomainError(Coupon expired), no events published; payment declined → Order.markFailed() + OrderFailed event вместо OrderCreated.
Бонус: InMemoryOrderRepo работает и для offline local development — поднял app без Postgres, всё запустилось.
Hexagonal vs Layered 3-tier — когда дополнительные ports окупаются.
Hexagonal применять когда: (1) ≥ 2 driving channels — REST + CLI + Kafka consumer, symmetry окупается, use case реализован один раз; (2) ожидаем смену infrastructure (миграция on-prem → cloud, Postgres → Aurora → DynamoDB); (3) сложная бизнес-логика, не CRUD, много правил для unit-тестов без БД; (4) долгоживущий продукт (5+ лет), пережил уже один framework upgrade; (5) команда понимает Dependency Inversion и согласна с дисциплиной.
Layered 3-tier применять когда: простой CRUD (admin panel, internal tool), один entry point (только REST), MVP/прототип, маленькая команда (1-3 человека), бизнес-логика тривиальна.
Компромисс: начать с layered, выделить hexagon когда добавляется второй driving channel. Преждевременная hexagonal на CRUD = cargo cult с 5 интерфейсами на 1 entity без выгоды. Реальный indicator: если кто-то писал unit-test для service слоя и руками мокал JPA EntityManager — hexagonal окупится. Если все тесты — integration с testcontainers, может layered и хватит.
Mapping DTO ↔ domain entity — где терпеть boilerplate, где нет.
Hexagonal требует разных моделей на границе: REST DTO (CreateOrderRequest), domain entity (Order), persistence DTO (OrderRow), event payload (OrderCreatedEvent). Соблазн — переиспользовать domain entity как REST DTO. Цена короткой дороги: (a) утечка domain через API — изменение private field ломает consumers; (b) JSON-сериализация диктует доменную модель — Date → string, Money теряет валюту; (c) versioning API становится versioning domain; (d) annotations (@JsonProperty, @Column) забивают entity.
Минимум разделять всегда: Inbound DTO → Domain (domain не знает HTTP); Domain → Outbound DTO (клиенты не видят internal fields); Domain ↔ Persistence (Repository маппит). Когда можно срезать угол — если REST shape == domain shape (internal admin API для одного клиента) — можно identity-mapper. Это редкий случай.
Правило mapping: explicit mappers (MapStruct, ts-morph generated) — они дают compile-error если поле забыли. Никаких BeanUtils.copyProperties reflection-magic — ломается тихо при добавлении полей.
OrderRepository (technology-bound), не OrderAdapter (entity-bound). Иначе adapter превращается в свалку.ResultSet / Row / JsonNode. Это утечка persistence-детали в core. Driven port должен возвращать domain object.if (repo instanceof PostgresOrderRepo) в service. Утечка абстракции, hexagonal сломан. Запрещено.