Specialization Frontend (React + TypeScript) — foundation ready. Open the frontend section →

Гексагональная архитектура

Что такое Ports & Adapters и зачем это нужно: изоляция бизнес-логики от баз данных, фреймворков и внешних API. Простое объяснение с нуля — порты, адаптеры, правило зависимостей и главный выигрыш для тестов.

Эталонная библиотека к статье hexagonal-architecture (annotations + ArchUnit)

← назад к разделу

Когда проект маленький, достаточно трёх слоёв: контроллер → сервис → репозиторий. Но с ростом проекта одна проблема начинает повторяться снова и снова: бизнес-логика оказывается перемешана с инфраструктурой. Сервис знает про ORM, про HTTP-клиент к платёжному шлюзу, про Kafka. Чтобы протестировать одно бизнес-правило, нужно поднять базу данных.

Гексагональная архитектура (её ещё называют Ports & Adapters, автор — Алистер Кокбёрн) решает одну конкретную задачу: держать бизнес-логику отдельно от всего внешнего.

Проблема трёхслойной архитектуры

Представьте сервис оформления заказов. В классическом варианте:

@Service
public class OrderService {
    @Autowired private OrderRepository repository;  // JPA-зависимость
    @Autowired private PaymentClient payment;       // HTTP-клиент
    @Autowired private KafkaTemplate<?, ?> kafka;  // брокер

    public Order createOrder(CreateOrderRequest request) {
        // логика перемешана с вызовами инфраструктуры
        Order order = new Order(request.getItems());
        payment.charge(order.getTotal());           // внешний вызов прямо здесь
        repository.save(order);                     // ORM прямо здесь
        kafka.send("orders", order);                // брокер прямо здесь
        return order;
    }
}

Что плохо:

  • чтобы протестировать расчёт суммы заказа, нужны моки для JPA, Kafka и HTTP-клиента;
  • замена платёжного провайдера затрагивает сам сервис с бизнес-логикой;
  • бизнес-правила читаются между строк с вызовами инфраструктуры.

Ключевая идея: домен в центре

Гексагональная архитектура разделяет всё на три зоны:

  [ REST API ]                            [ PostgreSQL ]
       |                                        |
   адаптер (входящий)              адаптер (исходящий)
       |                                        |
  [ порт ]                              [ порт ]
       |                                        |
       └──────────► ДОМЕН ◄────────────────────┘
  • Домен — сущности, бизнес-правила, логика. Не знает ни про базу данных, ни про HTTP, ни про фреймворк.
  • Порт — интерфейс, который домен определяет сам. «Мне нужно сохранить заказ» — это порт. Как именно — домену не важно.
  • Адаптер — реализация порта для конкретной технологии. PostgreSQL-адаптер реализует порт сохранения. HTTP-адаптер реализует порт отправки платежа.

Адаптеры бывают двух видов:

  • входящие (driving) — инициируют вызовы в домен: REST-контроллер, планировщик, Kafka-слушатель;
  • исходящие (driven) — домен вызывает их через порты: репозиторий, HTTP-клиент к внешнему сервису.

Правило зависимостей

Единственное, что нужно запомнить:

Зависимости всегда направлены внутрь. Домен не зависит ни от чего. Всё зависит от домена.

Адаптеры → Порты → Домен

Если в доменном классе появился импорт org.springframework, jakarta.persistence или com.fasterxml.jackson — правило нарушено.

Как выглядит код

Посмотрим на конкретный пример — сервис заказов.

Домен

Агрегат Order содержит бизнес-логику внутри себя и не знает ни про какую инфраструктуру:

public class Order {
    private Long id;
    private OrderStatus status;
    private PaymentOrder payment;
    private final List<OrderItem> items = new ArrayList<>();

    public void createPayment(String orderNumber, Money amount, UUID gatewayOrderId,
                              PaymentStatus status, String paymentFormUrl) {
        if (this.payment != null) {
            throw new PaymentOrderException.AlreadyExists();
        }
        this.payment = PaymentOrderFactory.create(
            this.id, orderNumber, amount, gatewayOrderId, status, paymentFormUrl);
    }

    public void cancel(InventoryReservation reservation) {
        if (this.status == OrderStatus.CANCELLED) return;
        this.status = OrderStatus.CANCELLED;
        this.cancelledAt = reservation.cancelledAt();
        for (OrderItem item : items) {
            item.cancel(reservation);
        }
    }

    public Money calculateTotalAmount() {
        return items.stream()
            .map(OrderItem::getPrice)
            .reduce(Money.ZERO, Money::add);
    }
}

Ни одной инфраструктурной аннотации. Нет @Entity, нет @JsonProperty. Бизнес-правила читаются напрямую: нельзя создать дублирующий платёж, отмена обновляет статус и дочерние позиции.

Исходящий порт

Домен описывает свои потребности через интерфейс — это порт:

// Определён в доменном слое — домен говорит что ему нужно
public interface OrderRepository {
    Optional<Order> findById(Long id);
    Order save(Order order);
}

public interface PaymentPort {
    PaymentRegisterResponse register(PaymentRegisterRequest request);
    PaymentStatusResponse getOrderStatus(UUID gatewayOrderId);
}

Исходящий адаптер

Адаптер реализует порт. Он живёт в отдельном модуле и знает про конкретную технологию:

// В модуле persistence — адаптер знает про jOOQ и БД
@Repository
@RequiredArgsConstructor
public class JooqOrderRepository implements OrderRepository {

    private final DSLContext dsl;
    private final OrderDomainRecordMapper mapper;

    @Override
    public Optional<Order> findById(Long id) {
        OrdersRecord record = dsl.selectFrom(ORDERS)
            .where(ORDERS.ID.eq(id))
            .fetchOneInto(OrdersRecord.class);
        if (record == null) return Optional.empty();
        return Optional.of(mapper.toDomainOrder(record));
    }

    @Override
    public Order save(Order order) {
        OrdersRecord record = mapper.toRecord(order);
        record.merge(dsl);
        return mapper.toDomainOrder(record);
    }
}

Маппинг между доменной моделью и строкой в БД — ответственность адаптера. Домен не знает про детали хранения.

Входящий адаптер: контроллер

Контроллер — тонкая прослойка между HTTP и бизнес-логикой:

@RestController
@RequiredArgsConstructor
public class OrderController {

    private final CreateOrderCommandHandler createOrderHandler;
    private final OrderHttpMapper mapper;

    @PostMapping("/orders")
    public ResponseEntity<OrderResponse> createOrder(@RequestBody CreateOrderRequest request) {
        CreateOrderCommand command = mapper.toCommand(request);
        Order result = createOrderHandler.handle(command);
        return ResponseEntity.ok(mapper.toResponse(result));
    }
}

Никакой бизнес-логики. Контроллер принимает HTTP, собирает команду, вызывает обработчик, возвращает JSON.

Обработчик (Use Case)

Вместо одного сервиса на 30 методов — каждая операция в отдельном классе:

@Component
@RequiredArgsConstructor
public class CreateOrderCommandHandler {

    private final InventoryPort inventoryPort;
    private final OrderRepository orderRepository;
    private final PaymentPort paymentPort;

    @Transactional
    public Order handle(CreateOrderCommand command) {
        InventoryReservation reservation = inventoryPort.reserveItems(command.getItems());
        Order order = OrderFactory.createFromReservation(command.getCustomerId(), reservation);
        return orderRepository.save(order);
    }
}

Обработчик оркестрирует: вызывает порты, создаёт доменные объекты, сохраняет. Сама бизнес-логика при этом живёт внутри Order.

Структура модулей

В трёхслойной архитектуре слои разделены пакетами — ничто не мешает контроллеру обратиться к репозиторию напрямую. В гексагональной границы физические: отдельные Gradle/Maven-модули.

orders-service/
├── core/           # Домен: сущности, порты, use cases
├── persistence/    # Адаптер: PostgreSQL
├── payment-out/    # Адаптер: платёжный шлюз
├── rest-api/       # Адаптер: REST-контроллеры
└── bootstrap/      # Точка входа: конфигурация, сборка

Хотите заменить платёжного провайдера? Пишете новый payment-out модуль, реализуете тот же PaymentPort — и всё. Домен и остальные адаптеры не меняются.

Главный выигрыш: тестирование

Изоляция домена напрямую влияет на скорость и простоту тестов.

Тест доменной модели запускается за миллисекунды, без базы данных:

class OrderTest {

    @Test
    void preventsDoublePayment() {
        Order order = testOrder();
        order.createPayment("ORD-1", Money.of(500), UUID.randomUUID(),
            PaymentStatus.REGISTERED, "https://pay.example.com");

        assertThatThrownBy(() -> order.createPayment("ORD-2", Money.of(500),
                UUID.randomUUID(), PaymentStatus.REGISTERED, "https://pay.example.com"))
            .isInstanceOf(PaymentOrderException.AlreadyExists.class);
    }

    @Test
    void calculatesTotalAmount() {
        Order order = testOrderWithItems(Money.of(300), Money.of(200));
        assertThat(order.calculateTotalAmount().amount())
            .isEqualByComparingTo("500.00");
    }
}

Тест use case мокирует только порты:

class CreateOrderCommandHandlerTest {

    private final InventoryPort inventory = mock(InventoryPort.class);
    private final OrderRepository orders = mock(OrderRepository.class);

    @Test
    void createsOrderFromReservation() {
        when(inventory.reserveItems(any())).thenReturn(testReservation());
        when(orders.save(any())).thenAnswer(inv -> inv.getArgument(0));

        Order result = handler.handle(new CreateOrderCommand("cart-1", List.of()));

        assertThat(result.getStatus()).isEqualTo(OrderStatus.BOOKING);
        verify(orders).save(any());
    }
}

Порты — интерфейсы, поэтому мокаются стандартными инструментами. Адаптеры тестируются отдельно: репозиторий — с реальной БД через Testcontainers, HTTP-адаптер — через заглушку (WireMock).

Частые ошибки

Анемичная модель. Когда Order — просто набор полей с геттерами/сеттерами, а вся логика вынесена в сервис — это нарушение идеи. Бизнес-правила должны жить внутри агрегата:

// Плохо: логика снаружи, модель — контейнер данных
order.setStatus(OrderStatus.CANCELLED);  // кто угодно, без проверок

// Хорошо: модель защищает инварианты
order.cancel(reservation);  // внутри — проверки, побочные эффекты

Инфраструктурные аннотации в домене. Если видите @Entity, @Table, @JsonProperty в доменном классе — граница нарушена. Аннотации ORM и JSON-маппинга живут в адаптерах, не в домене.

Доменные исключения наследуют инфраструктурные классы. OrderNotFoundException extends ResponseStatusException — плохо: домен узнал про HTTP. Доменные исключения чистые, маппинг в HTTP-коды происходит в адаптере:

// Домен — чистое исключение
public static final class NotFound extends OrderException {
    public NotFound(String message) { super(message); }
}

// Адаптер — маппинг в HTTP
@ExceptionHandler(OrderException.NotFound.class)
public ResponseEntity<ErrorResponse> handle(OrderException.NotFound ex) {
    return ResponseEntity.status(404).body(new ErrorResponse(ex.getMessage()));
}

Когда это нужно, а когда избыточно

Гексагональная архитектура хорошо подходит, когда:

  • есть сложная бизнес-логика с правилами и инвариантами;
  • несколько входных каналов (REST + Admin API + планировщик);
  • интеграции с внешними системами, которые могут меняться;
  • команда больше трёх-четырёх разработчиков и проект рассчитан на годы.

Избыточна для:

  • простых CRUD-сервисов без бизнес-логики;
  • прототипов и одноразовых скриптов;
  • микросервисов-прокси, которые только пересылают данные.

Коротко

  • Гексагональная архитектура = домен в центре, инфраструктура снаружи через адаптеры.
  • Порт — интерфейс, который домен определяет сам («мне нужно сохранить заказ»).
  • Адаптер — реализация порта для конкретной технологии (PostgreSQL, Stripe, Kafka).
  • Правило зависимостей: адаптеры зависят от домена, домен не зависит ни от чего.
  • Домен не импортирует Spring, JPA, Jackson и другие инфраструктурные классы.
  • Входящие адаптеры (контроллеры, планировщики) инициируют вызовы в домен.
  • Исходящие адаптеры (репозитории, HTTP-клиенты) домен вызывает через порты.
  • Главный выигрыш — тесты бизнес-логики без поднятия базы данных.
  • Анемичная модель, аннотации ORM в домене и исключения с HTTP-кодами — самые частые ошибки.

Что почитать дальше