Hexagonal architecture has one key rule: the core must not know about infrastructure. It doesn't know whether we use PostgreSQL or MySQL, Sber or some other payment provider, Kafka or RabbitMQ. This lets you swap out infrastructure without touching business logic.
But the core still needs to reach out somewhere — to read and write data, call a payment gateway, publish events. How do you do that without knowing about infrastructure?
The answer is ports. The core describes an interface saying "I need this," and the concrete implementation appears only in the adapter. Dependencies flow from adapters toward the core, not the other way around.
Where a port lives and how it's named
An outbound port is an interface in core/<bc>/port/out/. In core/, not in the adapter. The core declares what it needs; the adapter implements it.
core/src/main/java/<pkg>/
└── domain/
└── orders/ # bounded context
├── aggregate/Order.java
└── port/out/ # ← port interfaces live here
├── OrderRepository.java # working with the aggregate in the DB
├── OrderViewRepository.java # read projection for CQRS
├── PaymentPort.java # external payment gateway
├── NotificationPort.java # SMS / email
└── OrderEventPublisher.java # outgoing events
The naming convention is simple:
| What the port does | How to name it | Example |
|---|---|---|
| Stores and reads an aggregate | <X>Repository | OrderRepository |
| Returns read projections (CQRS) | <X>ViewRepository | OrderViewRepository |
| Calls an external HTTP system | <Y>Port | PaymentPort, SmsPort |
| Publishes events directly | <Z>EventPublisher | OrderEventPublisher |
Repository without a Port suffix is a historical convention from DDD — the name speaks for itself. Everything else gets a Port suffix: it's immediately clear this is a contract to an external system.
Port methods work with domain types
This is the key point. A port interface should look like part of the domain — no structures from external SDKs, no entities from the database layer.
// Correct — domain types
public interface PaymentPort {
RegisterResult register(RegisterCommand cmd); // RegisterCommand is a domain object
void cancel(PaymentId paymentId); // PaymentId is a domain Value Object
}
// Wrong — Sber-specific structures in the core
public interface PaymentPort {
SberRegisterResponse register(SberRegisterRequest req);
}
What's wrong with the SberRegisterRequest variant:
- The core now knows about Sber. If tomorrow we switch payment providers, we have to rewrite not just the adapter but everything that uses this interface.
- Handler tests are forced to create
SberRegisterRequest— and that means infrastructure details leaking into clean unit tests. - JSON annotations, snake_case fields, and other Sber API details have seeped into the core.
PaymentPort accepts a domain RegisterCommand (fields: amount, orderId, description) and returns a domain RegisterResult (paymentId, redirectUrl). The mapping to Sber API structures lives in SberClientAdapter inside a separate adapter module.
Exception hierarchy
A port is a contract, and exceptions are part of the contract too. Abstract exception classes are declared in core/; concrete subclasses live in the adapters.
// 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);
}
}
In the adapter — a concrete exception type tied to the implementation:
// 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);
}
}
}
The handler in the core catches the domain exception, not SberException:
@UseCaseHandler
public Payment handle(CreatePaymentCommand cmd) {
try {
return paymentPort.register(cmd);
} catch (PaymentDeclinedException e) {
// handle the decline
} catch (PaymentPortException e) {
throw new PaymentSystemUnavailableException(e);
}
}
If tomorrow you replace SberClientAdapter with an adapter for a different payment provider, the handler doesn't change. It only knows about domain exceptions.
The inbound port is a UseCase
In the classic description of hexagonal architecture there is an inbound port — an interface for entering the core. In UCP no separate interface is needed: the role of the inbound port is played by the UseCase + UseCaseHandler pairing, and the entry point is the UseCaseDispatcher.
// REST controller (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); // ← entry into the core
return ResponseEntity.created(...).body(mapper.toJson(order));
}
}
The controller doesn't depend on a concrete handler. It only knows the UseCaseDispatcher, which routes the command to the right handler by type on its own. This removes unnecessary dependencies and keeps the controller thin.
More on the role of UseCases and handlers — in the Use Case Pattern article.
Common mistakes
The port sits in the adapter module. A port is a contract from the core to infrastructure; it must live in core/. If you put it in the adapter, the core won't see the interface to inject the dependency — the dependency arrow flips the wrong way.
Optional where absence is an error. If the handler expects the object to definitely exist, it's better to throw a domain exception right away than to return an Optional and unwrap it at every call site:
// When absence is a normal case (query)
Optional<Order> findById(OrderId id);
// When absence is an error (command)
Order findRequired(OrderId id); // throws OrderNotFoundException
Or the handler decides for itself:
Order order = orderRepository.findById(cmd.id())
.orElseThrow(() -> new OrderNotFoundException(cmd.id()));
A port as an abstract class rather than an interface. A port is a contract, not a structure. An abstract class breaks testability: Mockito creates a mock for an interface almost instantly, but for a class it goes through bytecode manipulation. On top of that, Java has no multiple inheritance of classes: if the adapter already extends something else, it won't be able to implement a port class.
// Correct
public interface PaymentPort { ... }
// Wrong
public abstract class PaymentPort { ... }
In short
- A port is an interface in
core/<bc>/port/out/. The core describes what it needs; the adapter implements it. - Names:
<X>Repository(aggregate),<X>ViewRepository(CQRS),<Y>Port(external systems),<Z>EventPublisher(events). - Port methods accept and return domain types, not structures from external systems' SDKs.
- Exceptions: abstract classes in
core/, concrete subclasses in the adapters. The handler catches the domain exception. - Inbound port = UseCase + UseCaseDispatcher. No separate interface is needed.
- A port is always an interface, not a class: easier to substitute in tests, no restrictions on multiple inheritance.
What to read next
- Adapters out — who implements the port interface and how the out-adapter is structured.
- Adapters in — how a REST controller uses the
UseCaseDispatcheras the inbound entry point. - Use Case Pattern — about the
UseCaseDispatcherand the role of handlers. - Repository pattern in jOOQ — a concrete implementation of the
<X>Repositoryport via jOOQ.