Опирается на правила:
R-HEX-PORT-1…R-HEX-PORT-4иR-HEX-PORT-X1…R-HEX-PORT-X4из Hexagonal Style Guide → раздел 4. Ports.
Важно знать
- Outbound port — interface в
core/<bc>/port/out/. Описывает, что core нужно от внешнего мира.- Имена:
<X>Repository(persistence),<X>ViewRepository(CQRS read-side),<Y>Port(внешние HTTP-системы),<Z>EventPublisher(события без outbox).- Методы port'а оперируют domain-типами, не generated DTO внешней системы.
- Port-исключения — абстрактные в
core/, конкретные подклассы — в out-adapter'ах. Handler ловитPaymentPortException, неSberException.- Inbound port = UseCase. Отдельного «InboundPort»-интерфейса нет,
UseCaseDispatcherуже играет эту роль.- Port — всегда interface. Port-класс убивает testability — нечем подменить в тесте.
В Hexagonal архитектуре стрелка зависимостей выглядит так: bootstrap → core ← adapters. Чтобы это работало, core не должен знать про адаптеры. Но core нужно как-то ходить в БД, дергать платёжку, эмитить события. Решение — port-интерфейсы в core/. Core описывает, что ему нужно (как контракт), а adapter в своём модуле реализует этот контракт. Core держит указатель на port-интерфейс, фактическую реализацию подкладывает DI на старте приложения. Раскрытие правил R-HEX-PORT-* ниже.
Где живёт port и как он называется
R-HEX-PORT-1: outbound port — interface в core/<bc>/port/out/.
core/src/main/java/<pkg>/
└── domain/
└── orders/ # bounded context
├── aggregate/Order.java
└── port/out/ # ← port-интерфейсы здесь
├── OrderRepository.java # persistence (агрегат)
├── OrderViewRepository.java # read-проекция (CQRS)
├── PaymentPort.java # внешняя HTTP-система
├── NotificationPort.java # SMS / email
└── OrderEventPublisher.java # исходящие события (если не outbox)
Конвенция имён:
| Тип port'а | Имя | Назначение |
|---|---|---|
| Persistence write | <X>Repository | CRUD агрегата (OrderRepository) |
| Persistence read-projection | <X>ViewRepository | Read-DTO для CQRS (см. View-репозитории) |
| Внешняя HTTP-система | <Y>Port | PaymentPort, SmsPort, StoragePort |
| Исходящие события (без outbox) | <Z>EventPublisher | Когда события публикуются напрямую, не через outbox-relay |
<X>Repository без суффикса Port — историческое соглашение из DDD; «repository» уже самоговорящее имя. Для всего остального — суффикс Port, чтобы было видно: «это контракт к внешней системе».
Методы port'а оперируют domain-типами
R-HEX-PORT-2: ни generated DTO внешней системы, ни persistence-record'ы в сигнатуру не попадают.
// ХОРОШО
public interface PaymentPort {
RegisterResult register(RegisterCommand cmd); // ← RegisterCommand — domain DTO
void cancel(PaymentId paymentId); // ← PaymentId — domain VO
}
// ПЛОХО
public interface PaymentPort {
SberRegisterResponse register(SberRegisterRequest req); // ← generated DTO Sber'а
}
Что не так:
- Core знает про Sber. Domain типов больше нет; вместо них — структуры из Sber-SDK. Если завтра переходим на другую платёжку (OdnaKassa), правка нужна везде, где
SberRegisterRequestупомянут. - Тестируется через генерилку Sber'а. Тесты на handler'ах должны создавать
SberRegisterRequest-объект для каждого сценария. Это persistence-detail в чистом виде. - Mapping просочился в core.
SberRegisterRequestстроится по схеме Sber'а — поля camelCase или snake_case, JSON-аннотации, валидация. Это знание о Sber-API, не доменное знание.
PaymentPort принимает доменный RegisterCommand (amount, orderId, description) и возвращает доменный RegisterResult (paymentId, redirectUrl). Маппинг в Sber-API лежит в SberClientAdapter внутри sber-out-adapter-модуля.
Port-исключения — иерархия
R-HEX-PORT-3: абстрактные exception'ы в core/, конкретные — в out-adapter'ах.
// 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);
}
}
// core/domain/orders/port/out/PaymentDeclinedException.java
public class PaymentDeclinedException extends PaymentPortException {
public PaymentDeclinedException(PaymentId id, String reason) { /* ... */ }
}
И в out-adapter:
// 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 in Sber", e);
}
}
}
Handler в core ловит доменный exception, не специфический:
@UseCaseHandler
public Payment handle(CreatePaymentCommand cmd) {
try {
return paymentPort.register(cmd);
} catch (PaymentDeclinedException e) { // ← domain-meaning
// ...
} catch (PaymentPortException e) { // ← общий fallback
throw new PaymentSystemUnavailableException(e);
}
}
Сменили SberClientAdapter на OdnaKassaAdapter — handler не правится. Меняется только класс конкретного exception в out-adapter (OdnaKassaException extends PaymentPortException).
Inbound port = UseCase
R-HEX-PORT-4: отдельный «InboundPort»-интерфейс не нужен. В UCP UseCase + UseCaseHandler — это и есть вход в core, а UseCaseDispatcher играет роль того, что в классическом Hexagonal называют InboundPort.
// In-adapter (REST controller)
@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); // ← inbound-вход в core
return ResponseEntity.created(...).body(mapper.toJson(order));
}
}
Controller не зовёт CreateOrderCommandHandler напрямую. Он зовёт dispatcher, который сам найдёт нужный handler по типу команды. Это убирает дополнительную зависимость от каждого конкретного handler'а — controller знает только про dispatcher.
См. Use Case Pattern — про UseCaseDispatcher и роль handler'ов.
Что запрещено
R-HEX-PORT-X1: port в out-adapter (PaymentPort.java лежит в sber-out-adapter/).
Port — это контракт от core к инфраструктуре, он живёт в core. Adapter — реализация контракта, она в своём модуле. Если port положить в out-adapter:
- Core теряет интерфейс, к которому биндится — у него не будет, на что injectить.
- Стрелка зависимостей разворачивается — core начнёт зависеть от out-adapter, чтобы видеть интерфейс. Это
R-HEX-MOD-X2.
R-HEX-PORT-X2: generated DTO в port-сигнатуре — см. выше, R-HEX-PORT-2.
R-HEX-PORT-X3: Optional<<EntityRef>> где отсутствие = error.
// ПЛОХО
public interface OrderRepository {
Optional<Order> findRequired(OrderId id); // ← если null = error, не Optional
}
Если в этом use case отсутствие — это ошибка (например, command-handler ожидает что order существует), бросаем exception с domain-meaning:
// ХОРОШО
public interface OrderRepository {
Optional<Order> findById(OrderId id, SelectMode mode); // ← null = нормальный кейс (query)
Order findRequired(OrderId id, SelectMode mode); // ← throws OrderNotFoundException
}
Или handler сам решает:
Order order = orderRepository.findById(cmd.id(), SelectMode.FOR_UPDATE)
.orElseThrow(() -> new OrderNotFoundException(cmd.id()));
R-HEX-PORT-X4: port-классы вместо interfaces.
// ПЛОХО
public abstract class PaymentPort { // ← класс, не interface
public abstract RegisterResult register(RegisterCommand cmd);
}
Что не так:
- Невозможно подменить в тесте. Java позволяет переопределить abstract-метод подклассом, но это требует наследования вместо композиции.
- Java не поддерживает множественное наследование классов. Если adapter уже наследуется от чего-то (например,
AbstractRestClient), он не сможет implements port. - Тестовый mock тяжелее. Mockito создаёт mock для interface почти бесплатно; для класса — через CGLIB-byte-code-manipulation, медленнее и хрупче.
Port = interface. Это контракт, не структура.
Куда дальше
- Hexagonal Style Guide → раздел 4. Ports — нормативные формулировки.
- Adapters out — кто implements port-интерфейс.
- Adapters in — как REST-controller использует
UseCaseDispatcherкак inbound-вход. - Use Case Pattern — про
UseCaseDispatcherи почему отдельный inbound-port не нужен. - Repository pattern в jOOQ — конкретная реализация
<X>Repository-port'а через jOOQ.