Loading content...
If the domain is the heart of Hexagonal Architecture, Ports are its voice. Ports are interfaces—but not just any interfaces. They are the formal contracts that define exactly what your application can do and exactly what it needs from the outside world.
In the previous page, we established that the domain must be isolated from infrastructure. But a completely isolated domain is useless—it can't receive requests or persist data. Ports solve this problem elegantly: they define what the domain needs without specifying how to get it.
This distinction is crucial. A port might declare "I need to save an order" without specifying MySQL, PostgreSQL, or even a file system. The domain speaks in terms of its own concepts, and the outside world must adapt.
By the end of this page, you will understand the distinction between input ports (driving) and output ports (driven), how to design ports that truly isolate your domain, and the patterns that make ports effective. You'll see how ports enable testing, flexibility, and clean code organization.
A Port is an interface that sits at the boundary between your application's domain and the outside world. Think of it as a USB port on your laptop—it defines a standardized way to connect, regardless of what device you plug in.
In Hexagonal Architecture, ports serve two critical purposes:
Ports are always defined by the domain (or the application layer), never by the infrastructure. This is the key to maintaining the inward dependency direction.
While ports are implemented as interfaces in code, they carry semantic meaning beyond typical abstractions. A port represents an application boundary—a formal contract between the inside and outside of your hexagon. Not every interface in your codebase is a port; ports specifically mark the edges of your domain.
The Two Types of Ports
There are exactly two types of ports, distinguished by the direction of interaction:
Input Ports (Driving Ports / Primary Ports)
Input ports define what the application offers to the outside world. They represent the use cases or capabilities that external actors can invoke. When a user clicks a button, submits a form, or sends an API request, they're ultimately invoking an input port.
Examples:
SubmitOrderUseCase — Allows submitting a new orderTransferFundsUseCase — Allows transferring money between accountsRegisterUserUseCase — Allows creating a new user accountOutput Ports (Driven Ports / Secondary Ports)
Output ports define what the application needs from the outside world. They represent capabilities the domain requires but doesn't implement. When the domain needs to save data, send email, or query an external API, it uses an output port.
Examples:
OrderRepository — The domain needs to persist and retrieve ordersPaymentGateway — The domain needs to process paymentsNotificationService — The domain needs to send notificationsInput ports define the entry points to your application. They answer the question: What can the application do?
An input port is typically an interface (or abstract class) that declares one or more methods representing application use cases. The implementation of an input port is an Application Service (sometimes called a Use Case Service or Command Handler) that orchestrates domain logic to fulfill the use case.
Key Characteristics of Input Ports:
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110
// ============================================// INPUT PORTS (DRIVING PORTS)// ============================================// These interfaces define what the application CAN DO// They are implemented by Application Services (Use Case Handlers) package com.example.application.ports.input; // ------------------------------------------// Use Case: Submit Order// ------------------------------------------public interface SubmitOrderUseCase { /** * Submits an order for processing. * * @param command Contains all data required to submit an order * @return Result containing the order ID or validation errors * @throws InsufficientInventoryException if items are not in stock */ SubmitOrderResult execute(SubmitOrderCommand command);} // Command object - immutable, validated inputpublic record SubmitOrderCommand( CustomerId customerId, List<LineItemData> items, ShippingAddress shippingAddress, PaymentMethod paymentMethod) { // Self-validation in constructor public SubmitOrderCommand { Objects.requireNonNull(customerId, "Customer ID is required"); if (items == null || items.isEmpty()) { throw new IllegalArgumentException("At least one item is required"); } Objects.requireNonNull(shippingAddress, "Shipping address is required"); Objects.requireNonNull(paymentMethod, "Payment method is required"); }} // Result object - communicates all possible outcomespublic sealed interface SubmitOrderResult { record Success(OrderId orderId, Money total, EstimatedDelivery delivery) implements SubmitOrderResult {} record ValidationFailed(List<ValidationError> errors) implements SubmitOrderResult {} record PaymentDeclined(String reason) implements SubmitOrderResult {}} // ------------------------------------------// Use Case: Cancel Order// ------------------------------------------public interface CancelOrderUseCase { /** * Cancels an existing order. * * @param command Contains the order ID and cancellation reason * @return Result indicating success or failure with reason */ CancelOrderResult execute(CancelOrderCommand command);} public record CancelOrderCommand( OrderId orderId, String reason, CustomerId requestingCustomer) {} public sealed interface CancelOrderResult { record Success(RefundAmount refund) implements CancelOrderResult {} record OrderNotFound() implements CancelOrderResult {} record OrderNotCancellable(String reason) implements CancelOrderResult {} record NotAuthorized() implements CancelOrderResult {}} // ------------------------------------------// Query: Get Order Details// ------------------------------------------// Note: Queries can also be input ports (read-only operations)public interface GetOrderDetailsQuery { /** * Retrieves detailed information about an order. * * @param query Contains the order ID and requesting customer * @return Order details or not found indication */ OrderDetailsResult execute(GetOrderDetailsQuery.Query query); record Query(OrderId orderId, CustomerId requestingCustomer) {}} public sealed interface OrderDetailsResult { record Found(OrderDetails details) implements OrderDetailsResult {} record NotFound() implements OrderDetailsResult {} record NotAuthorized() implements OrderDetailsResult {}} /** * Notice what these input ports DON'T contain: * - No HTTP status codes * - No JSON annotations * - No database IDs (just domain IDs) * - No framework-specific types * - No infrastructure concerns * * They speak purely in domain terms. */Input ports often follow the Command-Query Separation (CQS) principle. Commands change state (SubmitOrder, CancelOrder). Queries read state (GetOrderDetails). Some architectures separate these into different port types entirely, leading to the Command Query Responsibility Segregation (CQRS) pattern.
Output ports define the external capabilities your application requires. They answer the question: What does the application need from the outside world?
An output port is an interface defined by the domain or application layer that declares operations the application needs but doesn't implement. The implementation is provided by an Adapter in the infrastructure layer.
Key Characteristics of Output Ports:
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173
// ============================================// OUTPUT PORTS (DRIVEN PORTS)// ============================================// These interfaces define what the application NEEDS// They are implemented by Infrastructure Adapters package com.example.application.ports.output; // ------------------------------------------// Repository Port: Order Persistence// ------------------------------------------public interface OrderRepository { /** * Saves an order (creates new or updates existing). * * @param order The domain order object * @return The saved order (may have updated fields like version) */ Order save(Order order); /** * Retrieves an order by its ID. * * @param orderId The domain order ID * @return The order if found, empty otherwise */ Optional<Order> findById(OrderId orderId); /** * Retrieves all orders for a customer. * * @param customerId The customer's ID * @return List of orders (never null, may be empty) */ List<Order> findByCustomerId(CustomerId customerId); /** * Retrieves orders by status. * * @param status The order status to filter by * @return List of matching orders */ List<Order> findByStatus(OrderStatus status); /** * Checks if an order exists. * * @param orderId The order ID to check * @return true if the order exists */ boolean exists(OrderId orderId); /** * Generates a new unique order ID. * * @return A new, unused OrderId */ OrderId nextId();} // ------------------------------------------// Gateway Port: Payment Processing// ------------------------------------------public interface PaymentGateway { /** * Attempts to authorize a payment. * * @param amount The amount to charge * @param method The payment method details * @return Authorization result with transaction ID or failure reason */ PaymentAuthorizationResult authorize(Money amount, PaymentMethod method); /** * Captures a previously authorized payment. * * @param authorizationId The prior authorization ID * @return Capture result */ PaymentCaptureResult capture(AuthorizationId authorizationId); /** * Refunds a captured payment. * * @param transactionId The transaction to refund * @param amount The amount to refund (may be partial) * @return Refund result */ RefundResult refund(TransactionId transactionId, Money amount);} public sealed interface PaymentAuthorizationResult { record Authorized(AuthorizationId id, TransactionId transactionId) implements PaymentAuthorizationResult {} record Declined(String reason, DeclineCode code) implements PaymentAuthorizationResult {} record Failed(String errorMessage) implements PaymentAuthorizationResult {}} // ------------------------------------------// Notification Port: User Communication// ------------------------------------------public interface NotificationService { /** * Sends an order confirmation to the customer. * * @param customerId Who to notify * @param order The order details * @return Notification result */ NotificationResult sendOrderConfirmation(CustomerId customerId, Order order); /** * Sends a shipping update notification. * * @param customerId Who to notify * @param orderId The order being shipped * @param trackingInfo Tracking details */ NotificationResult sendShippingUpdate( CustomerId customerId, OrderId orderId, TrackingInfo trackingInfo );} public sealed interface NotificationResult { record Sent(NotificationId id) implements NotificationResult {} record Failed(String reason) implements NotificationResult {}} // ------------------------------------------// External Service Port: Inventory// ------------------------------------------public interface InventoryService { /** * Checks if all items are available. * * @param items The items and quantities needed * @return Availability information per item */ InventoryCheckResult checkAvailability(List<LineItem> items); /** * Reserves inventory for an order. * * @param orderId The order reserving inventory * @param items Items to reserve * @return Reservation result */ ReservationResult reserve(OrderId orderId, List<LineItem> items); /** * Releases a previous reservation. * * @param reservationId The reservation to release */ void release(ReservationId reservationId);} /** * Notice what these output ports DON'T contain: * - No SQL or NoSQL query syntax * - No HTTP client details * - No SMTP configuration * - No external API specifics * - No infrastructure library imports * * The domain says WHAT it needs, not HOW to get it. */Output ports must not leak implementation details. An OrderRepository that exposes SQL-like query methods (findByQueryString(sql)) or database-specific pagination (findAll(PageRequest page)) has failed as an abstraction. The domain should not need to know about database pagination strategies—it should express what it needs in domain terms.
Well-designed ports are the foundation of a clean Hexagonal Architecture. Poor port design leads to awkward adapters, leaked abstractions, and eventually, a return to coupled code. Here are the principles that lead to effective ports:
Principle 1: Interface Segregation
Ports should be small and focused. Rather than one giant DataAccessPort with 50 methods, create focused ports: OrderRepository, CustomerRepository, ProductCatalog. This follows the Interface Segregation Principle—clients should not depend on methods they don't use.
DataAccessPort with 50 methodsOrderRepository with 5 methodsPrinciple 2: Domain Language
Ports speak the language of the domain, not the language of technology. Method names and parameter types come from the domain vocabulary.
| Technology-Speak ❌ | Domain-Language ✅ |
|---|---|
insert(OrderEntity entity) | save(Order order) |
executeQuery(String sql) | findByCustomer(CustomerId id) |
httpPost(String url, String json) | authorize(Money amount, PaymentMethod method) |
sendSmtpMessage(Message msg) | sendOrderConfirmation(CustomerId, Order) |
Principle 3: Complete Abstraction
Ports must completely hide implementation details. If an adapter needs additional configuration or setup, that must happen outside the port interface.
Bad: Repository.setConnectionString(String cs) — implementation leaked
Bad: Repository.beginTransaction() — transaction management leaked
Bad: PaymentGateway.setApiKey(String key) — configuration leaked
Good: All configuration happens when the adapter is constructed, outside the port contract.
Principle 4: Explicit Error Handling
Ports should make failure modes explicit. Use result types, sealed interfaces, or checked exceptions to communicate all possible outcomes. Avoid throwing generic runtime exceptions that adapters can't handle gracefully.
1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768
// ❌ POOR: Implicit error handling via exceptionspublic interface PaymentGateway { TransactionId authorize(Money amount, PaymentMethod method) throws PaymentException; // What kinds of failures? Recoverable?} // ✅ GOOD: Explicit error handling via result typespublic interface PaymentGateway { PaymentAuthorizationResult authorize(Money amount, PaymentMethod method);} public sealed interface PaymentAuthorizationResult { // Success case - clearly defined outcome record Authorized( AuthorizationId id, TransactionId transactionId ) implements PaymentAuthorizationResult {} // Card was declined - customer action needed record Declined( String reason, DeclineCode code, boolean retryable ) implements PaymentAuthorizationResult {} // Network/service failure - system action needed record ServiceUnavailable( String errorMessage, Duration retryAfter ) implements PaymentAuthorizationResult {} // Invalid input - programming error record InvalidRequest( List<ValidationError> errors ) implements PaymentAuthorizationResult {}} // Usage in application service:public class SubmitOrderService implements SubmitOrderUseCase { private final PaymentGateway paymentGateway; @Override public SubmitOrderResult execute(SubmitOrderCommand command) { // ... order validation ... var paymentResult = paymentGateway.authorize(total, command.paymentMethod()); // Exhaustive handling of all cases enforced by compiler return switch (paymentResult) { case PaymentAuthorizationResult.Authorized auth -> { // Proceed with order yield new SubmitOrderResult.Success(orderId, total, delivery); } case PaymentAuthorizationResult.Declined declined -> { yield new SubmitOrderResult.PaymentDeclined(declined.reason()); } case PaymentAuthorizationResult.ServiceUnavailable unavailable -> { // Could queue for retry or escalate yield new SubmitOrderResult.Error("Payment service unavailable"); } case PaymentAuthorizationResult.InvalidRequest invalid -> { // Log error, this shouldn't happen with validated input yield new SubmitOrderResult.Error("Invalid payment request"); } }; }}Principle 5: Testability by Design
Ports should be easy to mock or fake. This means:
If you find yourself struggling to write test doubles for a port, the port design is likely flawed.
Clear naming helps developers understand the role of each port. While conventions vary, here are widely-used patterns:
Input Port Naming:
Input ports represent use cases. Name them after the action they perform:
SubmitOrderUseCase — Clearly indicates a use caseTransferFundsCommand — Command pattern styleRegisterUserService — Service pattern styleGetOrderDetailsQuery — Query pattern styleThe suffix (UseCase, Command, Query, Service) is less important than consistency within your codebase.
| Port Type | Suffix Convention | Examples |
|---|---|---|
| Persistence | Repository | OrderRepository, CustomerRepository |
| External Service | Gateway or Client | PaymentGateway, ShippingClient |
| Messaging | Publisher or EventBus | OrderEventPublisher, DomainEventBus |
| Notification | Notifier or Service | EmailNotifier, NotificationService |
| Clock/Time | Clock or TimeProvider | SystemClock, TimeProvider |
| ID Generation | IdGenerator | OrderIdGenerator, UuidGenerator |
Package/Module Organization:
Ports are typically organized in one of two ways:
Option 1: Grouped by Direction
application/
ports/
input/
SubmitOrderUseCase.java
CancelOrderUseCase.java
output/
OrderRepository.java
PaymentGateway.java
Option 2: Grouped by Feature
application/
orders/
ports/
SubmitOrderUseCase.java
OrderRepository.java
payments/
ports/
ProcessPaymentUseCase.java
PaymentGateway.java
Both approaches work. Choose based on team size and application complexity. Feature grouping scales better for large applications with clear bounded contexts.
The specific naming convention matters less than consistent application. Pick a pattern and stick with it throughout the codebase. Inconsistency creates confusion and makes the architecture harder to understand.
One of the primary benefits of Hexagonal Architecture is testability. Ports make this possible by providing clear seams for test doubles.
Testing Input Ports:
Input port implementations (Application Services) are tested by providing mock output ports:
Testing Through Input Ports:
From the outside, tests invoke input ports without knowing implementation:
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111
// Testing an Application Service that implements an Input Port class SubmitOrderServiceTest { // Mocks for all output ports private OrderRepository orderRepository; private PaymentGateway paymentGateway; private InventoryService inventoryService; private NotificationService notificationService; // System under test (implements SubmitOrderUseCase input port) private SubmitOrderService submitOrderService; @BeforeEach void setUp() { orderRepository = mock(OrderRepository.class); paymentGateway = mock(PaymentGateway.class); inventoryService = mock(InventoryService.class); notificationService = mock(NotificationService.class); submitOrderService = new SubmitOrderService( orderRepository, paymentGateway, inventoryService, notificationService ); } @Test void submitOrder_withValidData_createsOrder() { // Arrange var command = new SubmitOrderCommand( new CustomerId("C123"), List.of(new LineItemData(new ProductId("P1"), 2)), new ShippingAddress("123 Main St", "City", "12345"), new PaymentMethod(PaymentType.CARD, "4111111111111111") ); when(orderRepository.nextId()).thenReturn(new OrderId("O456")); when(inventoryService.checkAvailability(any())) .thenReturn(new InventoryCheckResult.Available()); when(paymentGateway.authorize(any(), any())) .thenReturn(new PaymentAuthorizationResult.Authorized( new AuthorizationId("A789"), new TransactionId("T012") )); // Act - invoke through input port SubmitOrderResult result = submitOrderService.execute(command); // Assert assertInstanceOf(SubmitOrderResult.Success.class, result); var success = (SubmitOrderResult.Success) result; assertEquals(new OrderId("O456"), success.orderId()); // Verify interactions with output ports verify(orderRepository).save(any(Order.class)); verify(notificationService).sendOrderConfirmation(eq(command.customerId()), any()); } @Test void submitOrder_whenPaymentDeclined_returnsDeclined() { // Arrange var command = createValidCommand(); when(orderRepository.nextId()).thenReturn(new OrderId("O456")); when(inventoryService.checkAvailability(any())) .thenReturn(new InventoryCheckResult.Available()); when(paymentGateway.authorize(any(), any())) .thenReturn(new PaymentAuthorizationResult.Declined("Insufficient funds", DeclineCode.INSUFFICIENT_FUNDS, false)); // Act SubmitOrderResult result = submitOrderService.execute(command); // Assert assertInstanceOf(SubmitOrderResult.PaymentDeclined.class, result); // Verify order was NOT saved verify(orderRepository, never()).save(any()); } @Test void submitOrder_whenInventoryUnavailable_returnsValidationFailed() { // Arrange var command = createValidCommand(); when(orderRepository.nextId()).thenReturn(new OrderId("O456")); when(inventoryService.checkAvailability(any())) .thenReturn(new InventoryCheckResult.Unavailable( List.of(new ProductId("P1")) )); // Act SubmitOrderResult result = submitOrderService.execute(command); // Assert assertInstanceOf(SubmitOrderResult.ValidationFailed.class, result); // Verify payment was never attempted verify(paymentGateway, never()).authorize(any(), any()); }} /** * Key observations: * 1. Tests are fast (no real database, network, or services) * 2. Tests are isolated (each test controls its own dependencies) * 3. Tests are focused (testing one behavior at a time) * 4. Tests verify interactions through ports, not implementation * 5. Tests use domain types throughout (OrderId, not String) */Port-based testing follows the "test double" approach for unit tests. You'll still need integration tests to verify adapters work correctly with real infrastructure. But the bulk of your test suite can be fast unit tests because business logic is isolated behind ports.
We've explored Ports—the contracts that define your application's boundaries. Let's consolidate the key concepts:
What's Next:
Ports define what the application needs and offers. But ports are just interfaces—they don't do anything by themselves. In the next page, we'll explore Adapters, the implementations that connect ports to real-world infrastructure. You'll learn how to build adapters that fulfill port contracts while keeping infrastructure concerns out of your domain.
You now understand Ports—the interfaces that define your application's boundaries. You know the difference between input ports (what the application offers) and output ports (what it needs), and how proper port design enables testing and flexibility. Next, we'll implement Adapters to bring these contracts to life.