В гексагональной архитектуре есть одно ключевое правило: ядро (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>Repository | OrderRepository |
| Возвращает read-проекции (CQRS) | <X>ViewRepository | OrderViewRepository |
| Ходит во внешнюю HTTP-систему | <Y>Port | PaymentPort, SmsPort |
| Публикует события напрямую | <Z>EventPublisher | OrderEventPublisher |
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.