Hexagonal architecture

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

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

← back to section

When a project is small, three layers are enough: controller → service → repository. But as the project grows, one problem starts to repeat again and again: business logic ends up tangled with infrastructure. The service knows about the ORM, about the HTTP client to the payment gateway, about Kafka. To test a single business rule you have to spin up a database.

Hexagonal architecture (also called Ports & Adapters, introduced by Alistair Cockburn) solves one specific problem: keeping business logic separate from everything external.

The problem with three-layer architecture

Imagine an order-placement service. In the classic version:

@Service
public class OrderService {
    @Autowired private OrderRepository repository;  // JPA dependency
    @Autowired private PaymentClient payment;       // HTTP client
    @Autowired private KafkaTemplate<?, ?> kafka;  // broker

    public Order createOrder(CreateOrderRequest request) {
        // logic tangled with infrastructure calls
        Order order = new Order(request.getItems());
        payment.charge(order.getTotal());           // external call right here
        repository.save(order);                     // ORM right here
        kafka.send("orders", order);                // broker right here
        return order;
    }
}

What's wrong:

  • to test the order total calculation you need mocks for JPA, Kafka and the HTTP client;
  • swapping the payment provider touches the service that holds the business logic;
  • business rules have to be read between the lines of infrastructure calls.

The key idea: domain at the center

Hexagonal architecture splits everything into three zones:

  [ REST API ]                            [ PostgreSQL ]
       |                                        |
   adapter (inbound)               adapter (outbound)
       |                                        |
  [ port ]                              [ port ]
       |                                        |
       └──────────► DOMAIN ◄───────────────────┘
  • Domain — entities, business rules, logic. Knows nothing about the database, about HTTP, or about the framework.
  • Port — an interface that the domain defines itself. "I need to save the order" is a port. How exactly — the domain doesn't care.
  • Adapter — an implementation of a port for a concrete technology. The PostgreSQL adapter implements the save port. The HTTP adapter implements the payment port.

Adapters come in two kinds:

  • inbound (driving) — they initiate calls into the domain: REST controller, scheduler, Kafka listener;
  • outbound (driven) — the domain calls them through ports: repository, HTTP client to an external service.

The dependency rule

The only thing you need to remember:

Dependencies always point inward. The domain depends on nothing. Everything depends on the domain.

Adapters → Ports → Domain

If an import of org.springframework, jakarta.persistence or com.fasterxml.jackson appears in a domain class — the rule is broken.

What the code looks like

Let's look at a concrete example — the order service.

Domain

The Order aggregate contains business logic inside itself and knows nothing about any infrastructure:

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);
    }
}

Not a single infrastructure annotation. No @Entity, no @JsonProperty. The business rules read directly: you can't create a duplicate payment, cancellation updates the status and the child items.

Outbound port

The domain describes its needs through an interface — that's a port:

// Defined in the domain layer — the domain states what it needs
public interface OrderRepository {
    Optional<Order> findById(Long id);
    Order save(Order order);
}

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

Outbound adapter

The adapter implements the port. It lives in a separate module and knows about the concrete technology:

// In the persistence module — the adapter knows about jOOQ and the DB
@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);
    }
}

Mapping between the domain model and a database row is the adapter's responsibility. The domain knows nothing about storage details.

Inbound adapter: controller

The controller is a thin layer between HTTP and business logic:

@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));
    }
}

No business logic. The controller receives the HTTP request, assembles the command, calls the handler, returns JSON.

Handler (Use Case)

Instead of one service with 30 methods — each operation in its own class:

@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);
    }
}

The handler orchestrates: it calls ports, creates domain objects, saves. The business logic itself lives inside Order.

Module structure

In a three-layer architecture the layers are separated by packages — nothing stops the controller from reaching into the repository directly. In hexagonal, the boundaries are physical: separate Gradle/Maven modules.

orders-service/
├── core/           # Domain: entities, ports, use cases
├── persistence/    # Adapter: PostgreSQL
├── payment-out/    # Adapter: payment gateway
├── rest-api/       # Adapter: REST controllers
└── bootstrap/      # Entry point: configuration, assembly

Want to swap the payment provider? You write a new payment-out module, implement the same PaymentPort — and that's it. The domain and the other adapters don't change.

The main win: testing

Isolating the domain directly affects the speed and simplicity of tests.

A domain model test runs in milliseconds, without a database:

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");
    }
}

A use case test mocks only the ports:

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());
    }
}

Ports are interfaces, so they are mocked with standard tools. Adapters are tested separately: the repository — against a real DB via Testcontainers, the HTTP adapter — against a stub (WireMock).

Common mistakes

Anemic model. When Order is just a bag of fields with getters/setters, and all the logic is pushed out into a service — that breaks the idea. Business rules should live inside the aggregate:

// Bad: logic outside, the model is a data container
order.setStatus(OrderStatus.CANCELLED);  // anyone, no checks

// Good: the model protects its invariants
order.cancel(reservation);  // inside — checks, side effects

Infrastructure annotations in the domain. If you see @Entity, @Table, @JsonProperty in a domain class — the boundary is broken. ORM and JSON-mapping annotations live in the adapters, not in the domain.

Domain exceptions extending infrastructure classes. OrderNotFoundException extends ResponseStatusException is bad: the domain has learned about HTTP. Domain exceptions are clean; the mapping to HTTP codes happens in the adapter:

// Domain — a clean exception
public static final class NotFound extends OrderException {
    public NotFound(String message) { super(message); }
}

// Adapter — mapping to HTTP
@ExceptionHandler(OrderException.NotFound.class)
public ResponseEntity<ErrorResponse> handle(OrderException.NotFound ex) {
    return ResponseEntity.status(404).body(new ErrorResponse(ex.getMessage()));
}

When you need it, and when it's overkill

Hexagonal architecture is a good fit when:

  • there is complex business logic with rules and invariants;
  • there are several input channels (REST + Admin API + scheduler);
  • there are integrations with external systems that may change;
  • the team is larger than three or four developers and the project is meant to last for years.

It's overkill for:

  • simple CRUD services without business logic;
  • prototypes and one-off scripts;
  • proxy microservices that only forward data.

In short

  • Hexagonal architecture = domain at the center, infrastructure on the outside through adapters.
  • A port is an interface that the domain defines itself ("I need to save the order").
  • An adapter is an implementation of a port for a concrete technology (PostgreSQL, Stripe, Kafka).
  • The dependency rule: adapters depend on the domain, the domain depends on nothing.
  • The domain does not import Spring, JPA, Jackson and other infrastructure classes.
  • Inbound adapters (controllers, schedulers) initiate calls into the domain.
  • Outbound adapters (repositories, HTTP clients) are called by the domain through ports.
  • The main win is testing business logic without spinning up a database.
  • An anemic model, ORM annotations in the domain and exceptions carrying HTTP codes are the most common mistakes.

Further reading