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:

order domain порт REST controller browser порт Kafka consumer events порт scheduler schedule порт repository PostgreSQL порт payment adapter payment gateway порт event publisher Kafka снаружи адаптеры, внутри домен

The domain sits inside, everything foreign stays outside, and ports guard the border: the domain declares what it needs, and only the adapter knows how that is actually done. Changing the database or the payment gateway means rewriting one adapter, not the rules.

  • 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 know about ports Ports declared in the domain Domain knows about nobody

The arrow shows not the order of calls but who knows about whom: the domain names no class from the outside, so it compiles and runs under tests with no database and no web server at all. The price is an extra layer of interfaces even where there is only one implementation.

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