lenec ru

← все посты

Hexagonal architecture на практике: ports и adapters без догм

18K

Гексагональная архитектура (она же 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-ов и читаемость: глядя в каталог, видно, что делает приложение.

Теряешь время на маппинги между слоями и на дисциплину поддержания границ. На простых проектах это перевешивает выгоду. На сложных и долгоживущих — окупается через несколько месяцев, особенно когда меняется команда: новый человек быстрее находит, где «логика», а где «способ положить в базу».

Что запомнить

Гексагональная архитектура — про симметрию и инверсию зависимостей, а не про шесть граней на диаграмме. Полезна, когда есть нетривиальный домен и предполагаемое разнообразие транспортов. Главные точки контроля: домен не знает про инфраструктуру, порты определяются изнутри, адаптеры подключаются снаружи. Всё остальное — детали реализации, которые можно подгонять под конкретный проект.

Комментарии 0

  • Будьте первым, кто оставит комментарий.

Войдите, чтобы оставить комментарий.