Опирается на правила: R-HEX-PORT-1R-HEX-PORT-4 и R-HEX-PORT-X1R-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>RepositoryCRUD агрегата (OrderRepository)
Persistence read-projection<X>ViewRepositoryRead-DTO для CQRS (см. View-репозитории)
Внешняя HTTP-система<Y>PortPaymentPort, 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.