← назад к разделу

В гексагональной архитектуре есть одно ключевое правило: ядро (core) не должно знать про инфраструктуру. Оно не знает, PostgreSQL у нас или MySQL, Sber или какая-то другая платёжка, Kafka или RabbitMQ. Это позволяет менять инфраструктуру, не трогая бизнес-логику.

Но core всё равно должен куда-то ходить — читать и писать данные, вызывать платёжный шлюз, публиковать события. Как это сделать, не зная про инфраструктуру?

Ответ — ports. Core описывает интерфейс «мне нужно вот это», а конкретная реализация появляется только в адаптере. Зависимости текут от адаптеров к ядру, а не наоборот.

Где живёт port и как называется

Outbound port — это интерфейс в core/<bc>/port/out/. Именно в core/, не в адаптере. Core объявляет, что ему нужно, адаптер это реализует.

core/src/main/java/<pkg>/
└── domain/
    └── orders/                          # bounded context
        ├── aggregate/Order.java
        └── port/out/                    # ← port-интерфейсы здесь
            ├── OrderRepository.java     # работа с агрегатом в БД
            ├── OrderViewRepository.java # read-проекция для CQRS
            ├── PaymentPort.java         # внешний платёжный шлюз
            ├── NotificationPort.java    # SMS / email
            └── OrderEventPublisher.java # исходящие события

Соглашение по именам простое:

Что делает portКак называтьПример
Сохраняет и читает агрегат<X>RepositoryOrderRepository
Возвращает read-проекции (CQRS)<X>ViewRepositoryOrderViewRepository
Ходит во внешнюю HTTP-систему<Y>PortPaymentPort, SmsPort
Публикует события напрямую<Z>EventPublisherOrderEventPublisher

Repository без суффикса Port — историческое соглашение из DDD, имя говорит само за себя. Для всего остального — суффикс Port: сразу видно, что это контракт к внешней системе.

Методы port'а работают с доменными типами

Это ключевой момент. Интерфейс port'а должен выглядеть как часть домена — никаких структур из внешних SDK, никаких сущностей из слоя базы данных.

// Правильно — domain-типы
public interface PaymentPort {
    RegisterResult register(RegisterCommand cmd);   // RegisterCommand — доменный объект
    void cancel(PaymentId paymentId);               // PaymentId — доменный Value Object
}
// Неправильно — Sber-specific структуры в core
public interface PaymentPort {
    SberRegisterResponse register(SberRegisterRequest req);
}

Что плохо в варианте с SberRegisterRequest:

  • Core теперь знает про Sber. Если завтра меняем платёжку, приходится переписывать не только адаптер, но и всё, что использует этот интерфейс.
  • Тесты на handler'ах вынуждены создавать SberRegisterRequest — а это детали инфраструктуры в чистых unit-тестах.
  • В core просочились JSON-аннотации, snake_case-поля и прочие детали Sber API.

PaymentPort принимает доменный RegisterCommand (поля: amount, orderId, description) и возвращает доменный RegisterResult (paymentId, redirectUrl). Маппинг в структуры Sber-API живёт в SberClientAdapter внутри отдельного модуля адаптера.

Иерархия исключений

Port — это контракт, и исключения тоже часть контракта. Абстрактные классы исключений объявляются в core/, конкретные подклассы — в адаптерах.

// core/domain/orders/port/out/PaymentPortException.java
public abstract class PaymentPortException extends RuntimeException {
    protected PaymentPortException(String msg, Throwable cause) {
        super(msg, cause);
    }
}

// core/domain/orders/port/out/PaymentNotFoundException.java
public class PaymentNotFoundException extends PaymentPortException {
    public PaymentNotFoundException(PaymentId id) {
        super("Payment not found: " + id, null);
    }
}

В адаптере — конкретный тип исключения, привязанный к реализации:

// sber-out-adapter/.../SberException.java
public class SberException extends PaymentPortException {
    public SberException(String msg, Throwable cause) { super(msg, cause); }
}

// sber-out-adapter/.../SberClientAdapter.java
@Component
public class SberClientAdapter implements PaymentPort {
    @Override
    public RegisterResult register(RegisterCommand cmd) {
        try {
            return /* ... */;
        } catch (FeignException e) {
            throw new SberException("Failed to register payment", e);
        }
    }
}

Handler в core ловит доменное исключение, не SberException:

@UseCaseHandler
public Payment handle(CreatePaymentCommand cmd) {
    try {
        return paymentPort.register(cmd);
    } catch (PaymentDeclinedException e) {
        // обработка отказа
    } catch (PaymentPortException e) {
        throw new PaymentSystemUnavailableException(e);
    }
}

Если завтра заменить SberClientAdapter на адаптер другой платёжки — handler не меняется. Он знает только о доменных исключениях.

Inbound port — это UseCase

В классическом описании гексагональной архитектуры есть inbound port — интерфейс входа в ядро. В UCP отдельный интерфейс не нужен: роль inbound-port'а играет связка UseCase + UseCaseHandler, а точкой входа служит UseCaseDispatcher.

// REST-контроллер (in-adapter)
@RestController
public class OrderController implements OrdersApi {
    private final UseCaseDispatcher dispatcher;

    @Override
    public ResponseEntity<OrderJson> createOrder(@Valid CreateOrderRequest req) {
        var cmd = mapper.toCommand(req);
        var order = dispatcher.dispatch(cmd);   // ← вход в core
        return ResponseEntity.created(...).body(mapper.toJson(order));
    }
}

Контроллер не зависит от конкретного handler'а. Он знает только UseCaseDispatcher, который сам маршрутизирует команду к нужному handler'у по типу. Это убирает лишние зависимости и оставляет контроллер тонким.

Подробнее о роли UseCase и handler'ов — в статье Use Case Pattern.

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

Port лежит в модуле адаптера. Port — это контракт от core к инфраструктуре, он должен быть в core/. Если положить его в адаптер, core не увидит интерфейс для внедрения зависимости — стрелка зависимостей развернётся в неправильную сторону.

Optional там, где отсутствие — это ошибка. Если handler ожидает, что объект обязательно существует, лучше бросить доменное исключение сразу, чем возвращать Optional и разворачивать его в каждом вызове:

// Когда отсутствие — нормальный кейс (query)
Optional<Order> findById(OrderId id);

// Когда отсутствие — ошибка (command)
Order findRequired(OrderId id);  // бросает OrderNotFoundException

Или handler сам решает:

Order order = orderRepository.findById(cmd.id())
    .orElseThrow(() -> new OrderNotFoundException(cmd.id()));

Port как абстрактный класс, а не интерфейс. Port — это контракт, не структура. Абстрактный класс ломает тестируемость: Mockito создаёт mock для интерфейса почти мгновенно, для класса — через манипуляции с байт-кодом. А ещё в Java нет множественного наследования классов: если адаптер уже наследует что-то другое, он не сможет реализовать port-класс.

// Правильно
public interface PaymentPort { ... }

// Неправильно
public abstract class PaymentPort { ... }

Коротко

  • Port — это интерфейс в core/<bc>/port/out/. Core описывает, что ему нужно; адаптер реализует.
  • Имена: <X>Repository (агрегат), <X>ViewRepository (CQRS), <Y>Port (внешние системы), <Z>EventPublisher (события).
  • Методы port'а принимают и возвращают доменные типы, не структуры из SDK внешних систем.
  • Исключения: абстрактные классы в core/, конкретные подклассы — в адаптерах. Handler ловит доменное исключение.
  • Inbound port = UseCase + UseCaseDispatcher. Отдельный интерфейс не нужен.
  • Port всегда interface, не класс: легче подменять в тестах, нет ограничений на множественное наследование.

Что почитать дальше

  • Adapters out — кто реализует port-интерфейс и как устроен out-адаптер.
  • Adapters in — как REST-контроллер использует UseCaseDispatcher как inbound-вход.
  • Use Case Pattern — про UseCaseDispatcher и роль handler'ов.
  • Repository pattern в jOOQ — конкретная реализация <X>Repository-port'а через jOOQ.