Hexagonal architecture на практике: ports и adapters без догм
Гексагональная архитектура (она же ports & adapters) — одна из тех концепций, про которые проще всего написать что-то вроде «отделите бизнес-логику от инфраструктуры» и закончить статью. Проблема в том, что после такой формулировки в проде появляется код, в котором три уровня интерфейсов спускаются по вызову, доменные сущности всё равно знают про базу, а тестов не больше, чем было раньше. Я наступал на это в трёх проектах, и в каждом следующем формулировка постепенно становилась практичнее.
Расскажу, как я применяю порты и адаптеры в обычных проектах: где это имеет смысл, где избыточно, и какие конкретные решения окупаются через год.
В чём вообще идея
Алистер Кокберн в оригинальной статье писал не про шестиугольники, а про симметрию: «приложение должно одинаково запускаться из теста, из веба и из CLI». Шестиугольник на картинке — просто способ показать, что у домена нет «верха» и «низа», есть наружный мир, и интерфейсы в этот мир определяются изнутри домена.
Практически это означает три вещи.
- Доменный слой ничего не знает о фреймворках, БД и транспорте.
- Интерфейсы (порты) определяются в домене, а реализации (адаптеры) живут снаружи и подключаются по принципу зависимостей.
- Любой адаптер можно заменить, не трогая домен. Включая тестовый.
Если убрать модное название, это вариация классического dependency inversion. Новизна — не в принципе, а в дисциплине: где провести границу и как её охранять.
Что считать портом
Самый частый вопрос. Я делю порты на две группы.
Driving (входные) порты
Это интерфейсы, через которые внешний мир дёргает домен. Обычно это use case или application service. Например:
interface PlaceOrder {
fun handle(command: PlaceOrderCommand): OrderId
}HTTP-контроллер, обработчик очереди, CLI-команда — все они вызывают этот порт. Контроллер не вызывает доменный сервис напрямую, и не пишет «логику» в себе.
Driven (выходные) порты
Это интерфейсы, через которые домен говорит наружу: «дай мне клиента», «отправь письмо», «положи в очередь». Они декларируются доменом, реализуются адаптерами.
interface CustomerRepository {
fun findById(id: CustomerId): Customer?
}
interface OrderEventPublisher {
fun publish(event: OrderEvent)
}Главная ошибка тут — определять порты в терминах инфраструктуры. Если интерфейс называется JpaCustomerRepository или возвращает ResultSet, это уже не порт, это адаптер с дополнительным шагом.
Куда положить код
Структура каталогов важнее, чем кажется. Если границы видны только в голове, через полгода они исчезают. Я обычно делаю три модуля или пакета:
domain— доменные сущности, value objects, доменные сервисы. Не зависит ни от чего, кроме стандартной библиотеки.application— use case-ы, входные порты, выходные порты (интерфейсы). Зависит от domain.adapters— все реализации портов. Тут JPA, HTTP, Kafka, S3. Зависит от application и domain.
В Java/Kotlin это удобно делать тремя Gradle-модулями. Тогда компилятор сам не даст domain зависеть от Hibernate. В одном модуле границу приходится держать руками, и это всегда заканчивается «маленьким исключением, которое потом разрослось».
myapp/
domain/
src/main/kotlin/com/x/order/
Order.kt
OrderId.kt
application/
src/main/kotlin/com/x/order/
PlaceOrder.kt # use case
OrderRepository.kt # порт
adapters/
src/main/kotlin/com/x/order/
OrderController.kt # driving adapter
JpaOrderRepository.kt # driven adapterДоменная модель не знает про базу
Самая болезненная часть для команды, привыкшей к Active Record или к JPA-сущностям. Если на доменной сущности висит @Entity и @Column, она знает про базу. Это не катастрофа, но в шестиугольнике это означает компромисс.
Прагматичный путь: либо разделить доменную сущность и её JPA-двойника (то есть Order и OrderJpaEntity) с маппингом между ними, либо признать, что у вас не строгий шестиугольник, а упрощённая версия с одним классом, который выполняет обе роли.
Я в продуктовых сервисах чаще выбираю второй вариант: на 80% сценариев он работает, и стоимость поддержки маппинга не окупает чистоту. Но в местах, где доменная логика сложная и таблиц много, разделяю — это окупается.
Use case как первый класс
В шестиугольнике use case — это не «слой сервисов», это конкретное приложение домена для одного сценария. Один use case — один класс, одно публичное действие.
class PlaceOrderUseCase(
private val orders: OrderRepository,
private val customers: CustomerRepository,
private val publisher: OrderEventPublisher,
) : PlaceOrder {
override fun handle(command: PlaceOrderCommand): OrderId {
val customer = customers.findById(command.customerId)
?: throw CustomerNotFound(command.customerId)
val order = Order.place(customer, command.items)
orders.save(order)
publisher.publish(OrderPlaced(order.id, order.total))
return order.id
}
}Снаружи в этот use case ходят controller, обработчик очереди и CLI. Внутри — только домен и порты. Никакого EntityManager, никакого HttpServletRequest.
Тестирование
Главная отдача от шестиугольника — тесты. Use case можно протестировать без поднятого Spring, без базы, без Kafka. Достаточно подменить порты на стабы или fakes.
@Test
fun `places order for existing customer`() {
val orders = InMemoryOrderRepository()
val customers = InMemoryCustomerRepository().apply { save(aCustomer(id = "c1")) }
val publisher = RecordingPublisher()
val useCase = PlaceOrderUseCase(orders, customers, publisher)
val id = useCase.handle(PlaceOrderCommand(CustomerId("c1"), listOf(item("sku-1", 2))))
assertNotNull(orders.findById(id))
assertEquals(1, publisher.events.size)
}Я предпочитаю in-memory fakes мокам. Mock library легко превращает тесты в проверку «вызвался ли метод», а fakes тестируют поведение. И, что важно, тот же fake я использую как референс при разработке адаптера: если поведение реальной БД отличается от fake, обычно это баг адаптера.
Адаптеры по транспортам
На один use case может быть несколько входных адаптеров. Это нормально и часто полезно. Например, заказ оформляется через REST, через async-команду из Kafka и через CLI для оператора. Все три зовут один и тот же PlaceOrder.
@RestController
class OrderController(private val placeOrder: PlaceOrder) {
@PostMapping("/orders")
fun create(@RequestBody req: CreateOrderRequest): ResponseEntity<CreateOrderResponse> {
val id = placeOrder.handle(req.toCommand())
return ResponseEntity.ok(CreateOrderResponse(id.value))
}
}
class OrderCommandConsumer(private val placeOrder: PlaceOrder) {
fun onMessage(payload: PlaceOrderPayload) {
placeOrder.handle(payload.toCommand())
}
}В каждом адаптере — только перевод формата и обработка специфики транспорта (HTTP-коды, retry, ack). Никакой бизнес-логики.
Что обычно идёт не так
- Контроллер начинает «знать слишком много». Появляются проверки прав, ветвления по бизнес-условиям. Это значит, что use case определён слишком узко. Расширьте его или добавьте новый.
- Use case превращается в Transaction Script. Десять строк процедурной логики, которая дёргает порты. Бывает на простых сценариях — это нормально. Бывает на сложных — это сигнал, что доменная модель анемичная и поведение сидит в use case вместо сущностей.
- Порты «дегенерируют». Один порт под одного потребителя превращается в обёртку без смысла. Иногда это ок (для тестов), иногда стоит просто использовать готовый клиент напрямую — на тонких сервисах догматика только мешает.
- Адаптер БД знает про use case. Если в адаптере появляются методы вроде
findOrdersForPlaceOrderUseCase, направление зависимости перевернулось. Возвращайтесь к интерфейсуOrderRepositoryи решайте, что туда добавить. - Транзакции живут в адаптере. Хороший вопрос на ревью: где у вас открывается транзакция. По моему опыту, лучшее место — оборачивать use case (через интерсептор/декоратор), а не размазывать по адаптерам.
Где не применять
Шестиугольник не оправдан, если:
- Сервис тонкий (CRUD-обёртка над одной таблицей). Лишние слои на 80% кода — это просто шум.
- Жизненный цикл проекта короткий, а команда — два человека. Структура выживает на дисциплине, не на правилах.
- Сервис — экспериментальный прототип. Для прототипа достаточно одного слоя и тестов на критичные места.
В этих случаях я начинаю с минимума и добавляю слои по мере роста сложности. Лучше «переехать» на гексагон через 3 месяца, когда понятно, какие границы реально нужны, чем построить 5 слоёв на пустом месте.
Что выигрываешь, что теряешь
Выигрываешь устойчивость к смене инфраструктуры (поменяли БД, поменяли брокер, перешли с REST на gRPC — домен не трогается), быстрые тесты use case-ов и читаемость: глядя в каталог, видно, что делает приложение.
Теряешь время на маппинги между слоями и на дисциплину поддержания границ. На простых проектах это перевешивает выгоду. На сложных и долгоживущих — окупается через несколько месяцев, особенно когда меняется команда: новый человек быстрее находит, где «логика», а где «способ положить в базу».
Что запомнить
Гексагональная архитектура — про симметрию и инверсию зависимостей, а не про шесть граней на диаграмме. Полезна, когда есть нетривиальный домен и предполагаемое разнообразие транспортов. Главные точки контроля: домен не знает про инфраструктуру, порты определяются изнутри, адаптеры подключаются снаружи. Всё остальное — детали реализации, которые можно подгонять под конкретный проект.