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

Ports & Adapters: изоляция бизнес-логики от инфраструктуры. Структура Gradle multi-module, домен на чистой Java, тесты без Spring.

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

Все примеры в статье — на сквозном кейсе сайта: высоконагруженный маркетплейс. В этой статье мы фокусируемся на одном сервисе из C2-диаграммы — Order Service. Стек: Java 21, Spring Boot 3, jOOQ, Gradle multi-module.

Зачем ещё одна архитектура?

Классическая трёхслойная архитектура (Controller → Service → Repository) знакома каждому Java-разработчику. Она проста, понятна и… создаёт проблемы, когда проект растёт.

Типичная картина: сервисный слой знает про Spring, JPA, Kafka, REST-клиенты внешних систем. Бизнес-логика размазана между контроллерами и репозиториями. Юнит-тесты требуют поднятия половины Spring-контекста. Замена базы данных или брокера сообщений превращается в рефакторинг на месяц.

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

Ключевая идея

Представьте приложение как ядро, окружённое портами. Порт — это интерфейс: контракт взаимодействия. Адаптер — это реализация порта для конкретной технологии.

                          ┌──────────────────────────────┐
   REST API   ──adapter──►│  port                        │
                          │            ┌───────────┐     │
   Admin API  ──adapter──►│  port      │  DOMAIN   │     │──port──adapter──►  PostgreSQL (jOOQ)
                          │            │ (бизнес-  │     │
   Scheduler  ──adapter──►│  port      │  логика)  │     │──port──adapter──►  Платёжный шлюз
                          │            └───────────┘     │
                          │                              │──port──adapter──►  Складская система
                          │                              │
                          │                              │──port──adapter──►  Доставка
                          └──────────────────────────────┘

Три зоны:

  • Domain — сущности, value objects, бизнес-правила. Не знает ни про Spring, ни про базу данных, ни про HTTP. Чистая Java.
  • Порты — интерфейсы. Входящие (driving) описывают, что домен умеет делать. Исходящие (driven) описывают, что домену нужно от внешнего мира.
  • Адаптеры — реализации портов. REST-контроллер — входящий адаптер. jOOQ-репозиторий — исходящий адаптер. HTTP-клиент к платёжному шлюзу — тоже исходящий адаптер.

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

Единственное правило, которое нельзя нарушать:

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

Адаптеры ──зависят от──► Порты ──определены в──► Домен

Домен не импортирует spring-*, javax.persistence.*, com.fasterxml.jackson.*. Если вы видите такой импорт в доменном классе — архитектура нарушена.

Структура проекта: Gradle multi-module

В трёхслойке границы между слоями — это пакеты. Ничто не мешает контроллеру импортнуть репозиторий напрямую. В multi-module границы — это модули Gradle. Компилятор не даст адаптеру залезть в другой адаптер.

orders-service/                          # корневой проект
├── core/                                # Домен: модель, порты, use cases
│   └── src/main/java/
│       └── ru/example/orders/core/
│           ├── domain/
│           │   ├── aggregate/           # Aggregate Roots (Order)
│           │   ├── entity/              # Entities (OrderItem, PaymentOrder, Customer)
│           │   ├── valueobject/         # Value Objects (Money, ShippingAddress)
│           │   ├── factory/             # Фабрики создания доменных объектов
│           │   ├── port/out/            # Исходящие порты (внешние системы)
│           │   └── repository/          # Порты для персистенции
│           ├── usecase/
│           │   ├── command/             # Команды (изменяют состояние)
│           │   └── query/               # Запросы (только чтение)
│           ├── exception/               # Доменные исключения
│           └── service/                 # Доменные сервисы
│
├── persistence/                         # Исходящий адаптер: PostgreSQL + jOOQ
├── payment-out-adapter/                 # Исходящий адаптер: платёжный шлюз
├── inventory-out-adapter/               # Исходящий адаптер: складская система
├── shipping-out-adapter/                # Исходящий адаптер: служба доставки
├── receipt-out-adapter/                 # Исходящий адаптер: фискальные чеки
├── scheduler-out-adapter/               # Исходящий адаптер: фоновые задачи
│
├── customer-api-in-adapter/             # Входящий адаптер: REST API для клиентов
├── admin-api-in-adapter/                # Входящий адаптер: REST API для админки
│
└── bootstrap/                           # Точка входа: Spring Boot, конфиги, wiring

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

Домен: Aggregate Root

Order — центральный объект домена. Содержит бизнес-логику внутри себя: защищает инварианты, управляет дочерними сущностями.

@Getter
@Builder
public class Order {

    private Long id;
    private Long customerId;
    private Long reservationId;
    private OrderStatus status;
    private boolean needShippingInsurance;
    private OffsetDateTime expiresAt;
    private OffsetDateTime confirmedAt;
    private OffsetDateTime cancelledAt;

    private PaymentOrder payment;
    private final List<OrderItem> items = new ArrayList<>();
    private final List<Receipt> receipts = 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 depositPayment(PaymentStatusResponse response) {
        if (this.payment == null) {
            throw new PaymentOrderException.NotFound(
                "Payment not found for orderId=" + this.id);
        }
        this.payment.markDeposited(response);
        markPendingConfirmation();
    }

    // --- Бизнес-логика: подтверждение / отмена ---
    public void confirm(InventoryReservation reservation) {
        this.status = OrderStatus.CONFIRMED;
        this.confirmedAt = reservation.confirmedAt();
        for (OrderItem item : items) {
            ReservationLine line = findReservationLine(
                reservation, item.getReservationLineId());
            if (line != null) {
                item.confirm(line);
            }
        }
    }

    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 void markPendingConfirmation() {
        if (this.status == OrderStatus.PENDING_CONFIRMATION) return;
        if (this.status != OrderStatus.BOOKING) {
            throw new IllegalStateException(
                "Only BOOKING -> PENDING_CONFIRMATION, current=" + this.status);
        }
        this.status = OrderStatus.PENDING_CONFIRMATION;
    }

    // --- Бизнес-логика: расчёты ---
    public Money calculateTotalAmount() {
        Money itemsTotal = items.stream()
            .map(OrderItem::getPrice)
            .reduce(Money.ZERO, Money::add);
        Money insuranceTotal = getShippingInsurances().stream()
            .map(ShippingInsurance::getPrice)
            .reduce(Money.ZERO, Money::add);
        return itemsTotal.add(insuranceTotal);
    }

    // --- Бизнес-логика: страхование доставки ---
    public boolean canCancelShippingInsurance(Long itemId, OffsetDateTime now) {
        OrderItem item = findItemById(itemId).orElse(null);
        if (item == null || item.getShippingInsurance() == null || !needShippingInsurance) {
            return false;
        }
        ShippingInsurance insurance = item.getShippingInsurance();
        if (!insurance.isConfirmed()) return false;
        OffsetDateTime expectedDelivery = item.getExpectedDeliveryAt();
        if (expectedDelivery == null) return false;
        long secToDelivery = Duration.between(now, expectedDelivery).toSeconds();
        long daysSinceCreation = Duration.between(insurance.getCreatedAt(), now).toDays();
        return secToDelivery > 86400 && daysSinceCreation < 14;
    }
}

Ни одной аннотации @Entity, @Table, @Column. Это чистый Java-объект. Бизнес-правила живут в нём: нельзя создать дублирующий платёж, нельзя подтвердить заказ не из статуса BOOKING, отмена страховки доставки возможна только если до доставки > 1 дня и < 14 дней с покупки.

Домен: Value Objects

public record Money(BigDecimal amount) {

    public static final Money ZERO = new Money(BigDecimal.ZERO);

    public Money {
        Objects.requireNonNull(amount);
        amount = amount.setScale(2, RoundingMode.HALF_UP);
    }

    public static Money of(BigDecimal amount) {
        return amount == null ? ZERO : new Money(amount);
    }

    public Money add(Money other) {
        return new Money(this.amount.add(other.amount));
    }

    public Money subtract(Money other) {
        return new Money(this.amount.subtract(other.amount));
    }

    public boolean isPositive() {
        return amount.compareTo(BigDecimal.ZERO) > 0;
    }

    public long toKopecks() {
        return amount.movePointRight(2).longValueExact();
    }

    public static Money fromKopecks(long kopecks) {
        return new Money(BigDecimal.valueOf(kopecks, 2));
    }
}

Java record — идеальный инструмент для Value Objects: неизменяемый, с equals/hashCode из коробки. Компактный конструктор гарантирует инварианты: Money всегда имеет 2 знака после запятой. toKopecks() / fromKopecks() — конвертация для платёжного шлюза, который работает в копейках.

Домен: исходящие порты

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

// Порт: оплата (реализует payment-out-adapter)
public interface PaymentPort {
    PaymentRegisterResponse register(PaymentRegisterRequest request);
    PaymentRefundResponse refund(PaymentRefundRequest request);
    PaymentStatusResponse getOrderStatus(UUID gatewayOrderId);
}

// Порт: резервация на складе (реализует inventory-out-adapter)
public interface InventoryPort {
    List<Product> searchProducts(String query, Long categoryId, Long limit);
    AvailabilityList checkAvailability(AvailabilityFilter filter);
    InventoryReservation reserveItems(ReservationRequest request);
    InventoryReservation confirmReservation(Long id);
    InventoryReservation cancelReservation(Long id);
}

// Порт: страхование доставки (реализует shipping-out-adapter)
public interface ShippingInsurancePort {
    InsuranceCreationResult createInsurances(List<OrderItem> items);
    ShippingInsuranceDto getInsurance(UUID uuid);
    void confirm(List<UUID> uuids);
    ShippingInsuranceDto cancel(UUID uuid);
}

// Порт: персистенция (реализует persistence модуль)
public interface OrderRepository {
    Optional<Order> findById(Long id, SelectMode mode);
    Optional<Order> findOne(OrderFilter filter, SelectMode mode);
    List<Order> findAll(OrderFilter filter, int limit, SelectMode mode);
    Order save(Order order);
}

Домен говорит: «мне нужно зарезервировать товар на складе», «мне нужно провести оплату». Как именно — его не касается. Завтра поменяется платёжный провайдер, jOOQ заменят на Hibernate — домен останется прежним.

Входящие порты: Use Cases + CQRS

Вместо одного OrderService на 30 методов — каждая операция в отдельном классе. Команды меняют состояние, запросы только читают:

// Команда — record с данными для выполнения
public record CreateOrderCommand(
    String cartId,
    List<CreateOrderItemDto> items,
    Boolean needShippingInsurance
) implements UseCase<Order> {}

// Обработчик — один use case, один класс
@Component
@RequiredArgsConstructor
public class CreateOrderCommandHandler
        implements UseCaseHandler<CreateOrderCommand, Order> {

    private final InventoryPort inventoryPort;
    private final OrderRepository orderRepository;
    private final OrderConfirmationTaskRepository taskRepository;
    private final CustomerService customerService;
    private final ShippingInsurancePort shippingInsurancePort;
    private final CreateOrderToInventoryMapper mapper;

    @Transactional
    @Override
    public Order handle(CreateOrderCommand command) {
        // 1. Получаем текущего покупателя
        Customer customer = customerService.currentCustomer();

        // 2. Маппим DTO и резервируем товары на складе
        List<ReservationItemDto> reservationItems = mapper
            .toReservationItems(command.items());
        ReservationRequest request = mapper.toReservationRequest(
            command.cartId(), reservationItems, command.needShippingInsurance());
        InventoryReservation reservation = inventoryPort.reserveItems(request);

        // 3. Создаём доменный объект через фабрику
        Order order = OrderFactory.createFromReservation(
            customer.getId(), reservation, reservationItems,
            command.needShippingInsurance());

        // 4. Оформляем страховку доставки, если нужно
        if (command.needShippingInsurance()) {
            InsuranceCreationResult result =
                shippingInsurancePort.createInsurances(order.getItems());
            order.addShippingInsurances(result.insurances());
        }

        // 5. Сохраняем и создаём фоновую задачу подтверждения
        order = orderRepository.save(order);
        OrderConfirmationTask task = OrderConfirmationTaskFactory.createForOrder(
            order.getId(), order.getExpiresAt());
        taskRepository.save(task);

        return order;
    }
}

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

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

Контроллер — тонкая прослойка между HTTP и use cases:

@RestController
@RequiredArgsConstructor
public class OrderController implements OrdersApi {

    private final UseCaseDispatcher dispatcher;
    private final OrderJsonMapper orderJsonMapper;
    private final OrderItemMapper orderItemMapper;

    @Override
    public ResponseEntity<OrderResponse> createOrder(
            CreateOrderRequest request) {

        List<CreateOrderItemDto> items = orderItemMapper
            .toCreateOrderItemDtoList(request.getItems());

        CreateOrderCommand command = new CreateOrderCommand(
            request.getCartId(),
            items,
            request.getNeedShippingInsurance());

        // Диспетчер находит нужный handler по типу команды
        Order result = dispatcher.dispatch(command);

        OrderJson json = orderJsonMapper.toJson(result);
        return ResponseEntity.ok(
            new OrderResponse().success(true).data(json));
    }
}

Контроллер реализует сгенерированный интерфейс OrdersApi (из OpenAPI-спецификации). Его задача: принять HTTP → собрать команду → отдать диспетчеру → вернуть JSON. Никакой бизнес-логики.

UseCaseDispatcher — единая точка входа. Контроллер не знает, какой именно обработчик выполнит команду. Это даёт гибкость: можно добавить логирование, метрики, авторизацию — в одном месте.

Исходящий адаптер: платёжный шлюз

@Component
@RequiredArgsConstructor
public class PaymentGatewayAdapter implements PaymentPort {

    private final PaymentGatewayApi api;  // HTTP-клиент (Retrofit/OpenFeign)
    private final PaymentGatewayProperties config;
    private final PaymentAdapterMapper mapper;

    @Override
    public PaymentRegisterResponse register(PaymentRegisterRequest request) {
        PaymentApiRequest apiRequest = PaymentApiRequest.builder()
            .merchantId(config.merchantId())
            .secretKey(config.secretKey())
            .orderNumber(request.orderNumber())
            .amount(MoneyUtil.rublesToKopecks(request.amount()))
            .returnUrl(request.returnUrl())
            .build();

        PaymentApiResponse response = executeCall(api.register(apiRequest));
        return mapper.toRegisterResponse(response);
    }

    @Override
    public PaymentStatusResponse getOrderStatus(UUID gatewayOrderId) {
        PaymentStatusApiResponse response =
            executeCall(api.getOrderStatus(gatewayOrderId));
        return mapper.toStatusResponse(response);
    }
}

Адаптер маппит доменные DTO в формат конкретного API (платёжный шлюз обычно работает в копейках — MoneyUtil.rublesToKopecks()). Если провайдер сменит API — меняется только этот файл.

Исходящий адаптер: персистенция (jOOQ)

@Repository
@RequiredArgsConstructor
public class JooqOrderRepository implements OrderRepository {

    private final DSLContext dsl;
    private final OrderDomainRecordMapper mapper;

    @Override
    public Optional<Order> findById(Long id, SelectMode mode) {
        OrdersRecord record = dsl.selectFrom(ORDERS)
            .where(ORDERS.ID.eq(id))
            .forUpdate()  // или forShare — зависит от SelectMode
            .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);  // Insert or update
        return mapper.toDomainOrder(record);
    }
}

Маппинг между jOOQ-записями и доменной моделью — ответственность адаптера. Домен не знает про DSLContext, OrdersRecord или SQL.

Фабрика: инкапсуляция создания

Создание агрегата — нетривиальная операция. Нужно синхронизировать данные из внешней системы, связать позиции с резервацией, рассчитать время истечения. Фабрика инкапсулирует эту логику:

public final class OrderFactory {

    private OrderFactory() {}

    public static Order createFromReservation(Long customerId,
                                              InventoryReservation reservation,
                                              List<ReservationItemDto> items,
                                              boolean needShippingInsurance) {
        OffsetDateTime now = DateTimeUtil.currentDateTime();

        Order order = Order.builder()
            .customerId(customerId)
            .reservationId(reservation.id())
            .reservationHash(reservation.hash())
            .status(OrderStatus.BOOKING)
            .reservationExpiresAt(now.plusSeconds(reservation.secondsLeftToCancel()))
            .expiresAt(OrderExpiresDateTimeUtil
                .calculateExpiresAt(reservation.secondsLeftToCancel()))
            .needShippingInsurance(needShippingInsurance)
            .deleted(false)
            .createdAt(reservation.createdAt())
            .updatedAt(reservation.createdAt())
            .build();

        createItemsFromReservation(reservation.lines(), items)
            .forEach(order::addItemDirect);

        return order;
    }
}

Фабрика — часть домена. Она не зависит от Spring, базы данных или внешних API.

Доменные исключения

public abstract class OrderException extends RuntimeException {
    protected OrderException(String message) { super(message); }

    public static final class NotFound extends OrderException {
        public NotFound(String message) { super(message); }
    }
    public static final class NotPaid extends OrderException {
        public NotPaid() { super("Order has not been paid"); }
    }
    public static final class ReservationExpired extends OrderException {
        public ReservationExpired() { super("Reservation time has expired"); }
    }
}

Вложенные static final class — компактный паттерн: одна иерархия на агрегат, гранулярные типы для каждой бизнес-ошибки.

Доменные исключения не наследуют ничего из Spring — маппинг в HTTP-коды происходит в адаптере:

// bootstrap/RestExceptionHandler.java
@RestControllerAdvice
public class RestExceptionHandler {

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

    @ExceptionHandler(OrderException.ReservationExpired.class)
    public ResponseEntity<ErrorResponse> handle(OrderException.ReservationExpired ex) {
        return ResponseEntity.status(400)
            .body(new ErrorResponse(ex.getMessage()));
    }

    @ExceptionHandler(PaymentGatewayException.class)
    public ResponseEntity<ErrorResponse> handle(PaymentGatewayException ex) {
        return ResponseEntity.status(502)
            .body(new ErrorResponse("Payment service error"));
    }
}

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

Тест доменной модели — без Spring, без моков

class OrderTest {

    @Test
    void calculatesTotalWithShippingInsurance() {
        Order order = OrderFactory.createFromReservation(
            1L, testReservation(), testReservationItems(), true);
        order.addShippingInsurances(List.of(
            testShippingInsurance(Money.of(new BigDecimal("150.00")))));

        Money total = order.calculateTotalAmount();

        // цена товаров + страховка доставки
        assertThat(total.amount()).isEqualByComparingTo("650.00");
    }

    @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 cannotConfirmFromWrongStatus() {
        Order order = testOrder();
        order.cancel(testCancelReservation());

        assertThatThrownBy(() -> order.markPendingConfirmation())
            .isInstanceOf(IllegalStateException.class);
    }
}

Тесты запускаются за миллисекунды. Не нужен @SpringBootTest, не нужен Testcontainers. Тестируется чистая бизнес-логика: расчёты, инварианты, переходы статусов.

Тест use case — с моками портов

class CreateOrderCommandHandlerTest {

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

    @Test
    void createsOrderWithShippingInsurance() {
        when(inventory.reserveItems(any())).thenReturn(testReservation());
        when(shippingInsurance.createInsurances(any()))
            .thenReturn(new InsuranceCreationResult(testInsurances()));
        when(orders.save(any())).thenAnswer(inv -> inv.getArgument(0));

        CreateOrderCommand command = new CreateOrderCommand(
            "cart-1", testItemDtos(), true);

        Order result = handler.handle(command);

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

Порты — интерфейсы, поэтому мокаются тривиально. Адаптеры тестируются отдельно: persistence — через Testcontainers + PostgreSQL, payment-адаптер — через WireMock.

Трёхслойная vs гексагональная

АспектТрёхслойнаяГексагональная
Границыпакеты (можно нарушить)Gradle-модули (компилятор не даст)
Направление зависимостейсверху вниз (Controller → Service → Repo)снаружи внутрь (Adapters → Domain)
Где бизнес-логикаразмазана по сервисамвнутри агрегатов и use cases
Зависимость от фреймворкаService знает про Spring, JPAдомен на чистой Java
Замена внешней системырефакторинг сервисного слояновый адаптер, один модуль
Юнит-тесты доменамоки JPA, Spring-контекстработают без фреймворков
Количество модулей110+
Порог входанизкийсредний

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

1. Анемичная модель

// Плохо: логика снаружи, модель — контейнер данных
public class Order {
    private OrderStatus status;
    public void setStatus(OrderStatus s) { this.status = s; }
}
// orderService.cancel(order):
order.setStatus(OrderStatus.CANCELLED);  // кто угодно может поставить любой статус

// Хорошо: модель защищает свои инварианты
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);
    }
}

2. Аннотации JPA/Jackson в домене

// Плохо: домен привязан к инфраструктуре
@Entity
@Table(name = "orders")
public class Order {
    @Id private Long id;
    @Enumerated(EnumType.STRING) private OrderStatus status;
    @JsonProperty("total") private Money total;
}

// Хорошо: домен чистый, маппинг в адаптерах
@Getter
@Builder
public class Order {
    private Long id;
    private OrderStatus status;
}

3. God Service вместо use cases

// Плохо: один сервис на 30 методов
public class OrderService {
    public Order create(...) { }
    public void pay(...) { }
    public void cancel(...) { }
    public void confirm(...) { }
    public List<Order> findAll(...) { }
    public void processExpired(...) { }
    public void syncFromProvider(...) { }
}

// Хорошо: один use case — один класс
CreateOrderCommandHandler
ConfirmOrderCommandHandler
CancelOrderCommandHandler
CreatePaymentOrderCommandHandler
ProcessOrderConfirmationTaskCommandHandler
SyncOrderCommandHandler
GetOrderByIdQueryHandler
GetOrdersQueryHandler

4. Доменные исключения наследуют Spring-классы

// Плохо: домен привязан к HTTP
public class OrderNotFoundException extends ResponseStatusException {
    public OrderNotFoundException() { super(HttpStatus.NOT_FOUND); }
}

// Хорошо: доменное исключение, маппинг в RestExceptionHandler
public static final class NotFound extends OrderException {
    public NotFound(String message) { super(message); }
}

Когда применять

Подходит:

  • Сложная бизнес-логика с правилами и инвариантами
  • Несколько входных каналов (REST API + Admin API + планировщик)
  • Интеграция с внешними системами, которые могут меняться
  • Команда больше 3-4 разработчиков
  • Долгоживущий проект

Избыточна:

  • CRUD без бизнес-логики
  • Прототипы и MVP
  • Простые микросервисы-прокси
  • Проект на одного разработчика на 2 месяца

Итого

Гексагональная архитектура — это не про количество модулей и интерфейсов. Это про одно правило: домен не зависит от инфраструктуры.

Бизнес-логика живёт в чистых Java-классах: Order знает как отменить заказ, Money гарантирует 2 знака после запятой, OrderFactory инкапсулирует создание. Фреймворки, базы данных, платёжные шлюзы подключаются снаружи через адаптеры. Каждый адаптер — отдельный Gradle-модуль с физической границей.

Цена — больше модулей, классов, интерфейсов. Выигрыш — код, который можно тестировать без Spring-контекста, менять без страха сломать всё остальное, и масштабировать командой, где каждый работает со своим адаптером.

Ссылки