Гексагональная архитектура
Что такое Ports & Adapters и зачем это нужно: изоляция бизнес-логики от баз данных, фреймворков и внешних API. Простое объяснение с нуля — порты, адаптеры, правило зависимостей и главный выигрыш для тестов.
Когда проект маленький, достаточно трёх слоёв: контроллер → сервис → репозиторий. Но с ростом проекта одна проблема начинает повторяться снова и снова: бизнес-логика оказывается перемешана с инфраструктурой. Сервис знает про 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-кодами — самые частые ошибки.
Что почитать дальше
- CQRS: разделение чтения и записи — как разделить команды и запросы внутри гексагональной структуры.
- Тактические паттерны DDD — Aggregate, Value Object, Domain Event.
- Стандарт гексагональной архитектуры — правила именования, структура модулей, типичные ошибки.