Hexagonal architecture
Что такое Ports & Adapters и зачем это нужно: изоляция бизнес-логики от баз данных, фреймворков и внешних API. Простое объяснение с нуля — порты, адаптеры, правило зависимостей и главный выигрыш для тестов.
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
- CQRS: separating reads and writes — how to split commands and queries within a hexagonal structure.
- Tactical DDD patterns — Aggregate, Value Object, Domain Event.
- The hexagonal architecture standard — naming rules, module structure, common mistakes.