Appearance
Hexagonal Architecture (Ports and Adapters)
Introduction
Hexagonal Architecture, also known as Ports and Adapters, is a software design pattern introduced by Alistair Cockburn that decouples the core business logic of an application from its external dependencies such as databases, web frameworks, and messaging systems. By placing the domain at the center and defining explicit boundaries through ports (interfaces) and adapters (implementations), this architecture enables superior testability, flexibility, and long-term maintainability. It is a foundational concept for building resilient, evolvable systems in both monolithic and microservice contexts.
Core Concepts
The Problem with Traditional Layered Architecture
In a traditional layered architecture (Controller → Service → Repository), business logic often leaks into the infrastructure layer or becomes tightly coupled with frameworks. Changing a database or swapping a REST API for a message queue becomes a surgical operation that risks breaking core logic. Hexagonal Architecture solves this by inverting the dependency direction — the domain depends on nothing, and everything else depends on the domain.
The Hexagon
The hexagon is a metaphor — the shape is not important, but the concept of having multiple equivalent entry and exit points is. Each side of the hexagon represents a way the outside world interacts with (or is interacted with by) the application.
Ports
Ports are interfaces defined by the application core. They specify the contracts that the outside world must fulfill or that the outside world can call. There are two types:
- Driving Ports (Primary/Inbound): Define the use cases the application offers. The outside world calls in through these ports (e.g.,
OrderService,CreateUserUseCase). - Driven Ports (Secondary/Outbound): Define what the application needs from the outside world. The application calls out through these ports (e.g.,
OrderRepository,PaymentGateway).
Adapters
Adapters are the concrete implementations that bridge the outside world and the ports:
- Driving Adapters (Primary): Translate external input into calls on driving ports (e.g., a REST controller, a CLI handler, a Kafka consumer).
- Driven Adapters (Secondary): Implement driven ports using real infrastructure (e.g., a JPA repository, an HTTP client, an SMTP email sender).
Dependency Rule
The most critical rule: dependencies always point inward. The domain core has zero dependencies on adapters or frameworks. Adapters depend on ports. Ports are owned by the domain.
Implementation: Order Management System
Let's build a complete example — an order management system using Hexagonal Architecture in Java.
Package Structure
com.example.orders/
├── domain/
│ ├── model/
│ │ ├── Order.java
│ │ ├── OrderItem.java
│ │ ├── OrderStatus.java
│ │ └── OrderId.java
│ ├── exception/
│ │ └── OrderNotFoundException.java
│ └── service/
│ └── OrderDomainService.java
├── port/
│ ├── inbound/
│ │ ├── CreateOrderUseCase.java
│ │ ├── GetOrderUseCase.java
│ │ └── CancelOrderUseCase.java
│ └── outbound/
│ ├── OrderRepository.java
│ ├── PaymentGateway.java
│ └── NotificationSender.java
├── application/
│ └── OrderApplicationService.java
└── adapter/
├── inbound/
│ ├── rest/
│ │ └── OrderRestController.java
│ └── messaging/
│ └── OrderEventConsumer.java
└── outbound/
├── persistence/
│ └── JpaOrderRepository.java
├── payment/
│ └── StripePaymentAdapter.java
└── notification/
└── SesNotificationAdapter.javaDomain Model
java
package com.example.orders.domain.model;
import java.math.BigDecimal;
import java.time.Instant;
import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
import java.util.UUID;
public class Order {
private final OrderId id;
private final String customerId;
private final List<OrderItem> items;
private OrderStatus status;
private final Instant createdAt;
private BigDecimal totalAmount;
// Factory method — enforces invariants at creation time
public static Order create(String customerId, List<OrderItem> items) {
if (customerId == null || customerId.isBlank()) {
throw new IllegalArgumentException("Customer ID must not be blank");
}
if (items == null || items.isEmpty()) {
throw new IllegalArgumentException("Order must have at least one item");
}
return new Order(
new OrderId(UUID.randomUUID().toString()),
customerId,
new ArrayList<>(items),
OrderStatus.PENDING,
Instant.now()
);
}
private Order(OrderId id, String customerId, List<OrderItem> items,
OrderStatus status, Instant createdAt) {
this.id = id;
this.customerId = customerId;
this.items = items;
this.status = status;
this.createdAt = createdAt;
this.totalAmount = calculateTotal();
}
public void confirm() {
if (this.status != OrderStatus.PENDING) {
throw new IllegalStateException(
"Cannot confirm order in status: " + this.status);
}
this.status = OrderStatus.CONFIRMED;
}
public void cancel() {
if (this.status == OrderStatus.SHIPPED || this.status == OrderStatus.DELIVERED) {
throw new IllegalStateException(
"Cannot cancel order in status: " + this.status);
}
this.status = OrderStatus.CANCELLED;
}
private BigDecimal calculateTotal() {
return items.stream()
.map(item -> item.price().multiply(BigDecimal.valueOf(item.quantity())))
.reduce(BigDecimal.ZERO, BigDecimal::add);
}
// Getters
public OrderId getId() { return id; }
public String getCustomerId() { return customerId; }
public List<OrderItem> getItems() { return Collections.unmodifiableList(items); }
public OrderStatus getStatus() { return status; }
public Instant getCreatedAt() { return createdAt; }
public BigDecimal getTotalAmount() { return totalAmount; }
}java
package com.example.orders.domain.model;
public record OrderId(String value) {
public OrderId {
if (value == null || value.isBlank()) {
throw new IllegalArgumentException("OrderId must not be blank");
}
}
}
public record OrderItem(String productId, String productName,
BigDecimal price, int quantity) {
public OrderItem {
if (quantity <= 0) throw new IllegalArgumentException("Quantity must be positive");
if (price.compareTo(BigDecimal.ZERO) < 0)
throw new IllegalArgumentException("Price must not be negative");
}
}
public enum OrderStatus {
PENDING, CONFIRMED, SHIPPED, DELIVERED, CANCELLED
}Ports (Interfaces)
java
// ====== DRIVING PORTS (Inbound) ======
package com.example.orders.port.inbound;
import com.example.orders.domain.model.Order;
import com.example.orders.domain.model.OrderItem;
import java.util.List;
public interface CreateOrderUseCase {
Order createOrder(String customerId, List<OrderItem> items);
}
public interface GetOrderUseCase {
Order getOrder(String orderId);
}
public interface CancelOrderUseCase {
Order cancelOrder(String orderId);
}
// ====== DRIVEN PORTS (Outbound) ======
package com.example.orders.port.outbound;
import com.example.orders.domain.model.Order;
import com.example.orders.domain.model.OrderId;
import java.util.Optional;
public interface OrderRepository {
Order save(Order order);
Optional<Order> findById(OrderId id);
void delete(OrderId id);
}
public interface PaymentGateway {
PaymentResult charge(String customerId, java.math.BigDecimal amount);
}
public record PaymentResult(boolean success, String transactionId, String errorMessage) {}
public interface NotificationSender {
void sendOrderConfirmation(String customerId, String orderId);
void sendOrderCancellation(String customerId, String orderId);
}Application Service (Use Case Orchestrator)
java
package com.example.orders.application;
import com.example.orders.domain.exception.OrderNotFoundException;
import com.example.orders.domain.model.*;
import com.example.orders.port.inbound.*;
import com.example.orders.port.outbound.*;
import java.util.List;
public class OrderApplicationService
implements CreateOrderUseCase, GetOrderUseCase, CancelOrderUseCase {
private final OrderRepository orderRepository;
private final PaymentGateway paymentGateway;
private final NotificationSender notificationSender;
public OrderApplicationService(OrderRepository orderRepository,
PaymentGateway paymentGateway,
NotificationSender notificationSender) {
this.orderRepository = orderRepository;
this.paymentGateway = paymentGateway;
this.notificationSender = notificationSender;
}
@Override
public Order createOrder(String customerId, List<OrderItem> items) {
// 1. Create domain object (validates invariants)
Order order = Order.create(customerId, items);
// 2. Process payment through driven port
PaymentResult payment = paymentGateway.charge(
customerId, order.getTotalAmount());
if (!payment.success()) {
throw new RuntimeException(
"Payment failed: " + payment.errorMessage());
}
// 3. Confirm the order
order.confirm();
// 4. Persist through driven port
Order savedOrder = orderRepository.save(order);
// 5. Notify through driven port
notificationSender.sendOrderConfirmation(
customerId, savedOrder.getId().value());
return savedOrder;
}
@Override
public Order getOrder(String orderId) {
return orderRepository.findById(new OrderId(orderId))
.orElseThrow(() -> new OrderNotFoundException(orderId));
}
@Override
public Order cancelOrder(String orderId) {
Order order = getOrder(orderId);
order.cancel(); // domain logic enforces state transitions
Order saved = orderRepository.save(order);
notificationSender.sendOrderCancellation(
order.getCustomerId(), orderId);
return saved;
}
}Driving Adapter: REST Controller
java
package com.example.orders.adapter.inbound.rest;
import com.example.orders.domain.model.Order;
import com.example.orders.domain.model.OrderItem;
import com.example.orders.port.inbound.*;
import java.math.BigDecimal;
import java.util.List;
import java.util.Map;
// Simulated REST controller (framework-agnostic for clarity)
public class OrderRestController {
private final CreateOrderUseCase createOrderUseCase;
private final GetOrderUseCase getOrderUseCase;
private final CancelOrderUseCase cancelOrderUseCase;
public OrderRestController(CreateOrderUseCase createOrderUseCase,
GetOrderUseCase getOrderUseCase,
CancelOrderUseCase cancelOrderUseCase) {
this.createOrderUseCase = createOrderUseCase;
this.getOrderUseCase = getOrderUseCase;
this.cancelOrderUseCase = cancelOrderUseCase;
}
// POST /orders
public Map<String, Object> handleCreateOrder(CreateOrderRequest request) {
try {
List<OrderItem> items = request.items().stream()
.map(i -> new OrderItem(i.productId(), i.name(),
i.price(), i.quantity()))
.toList();
Order order = createOrderUseCase.createOrder(
request.customerId(), items);
return Map.of(
"status", 201,
"orderId", order.getId().value(),
"totalAmount", order.getTotalAmount(),
"orderStatus", order.getStatus().name()
);
} catch (IllegalArgumentException e) {
return Map.of("status", 400, "error", e.getMessage());
} catch (RuntimeException e) {
return Map.of("status", 500, "error", e.getMessage());
}
}
// GET /orders/{id}
public Map<String, Object> handleGetOrder(String orderId) {
try {
Order order = getOrderUseCase.getOrder(orderId);
return Map.of(
"status", 200,
"orderId", order.getId().value(),
"customerId", order.getCustomerId(),
"totalAmount", order.getTotalAmount(),
"orderStatus", order.getStatus().name()
);
} catch (Exception e) {
return Map.of("status", 404, "error", e.getMessage());
}
}
// DELETE /orders/{id}
public Map<String, Object> handleCancelOrder(String orderId) {
try {
Order order = cancelOrderUseCase.cancelOrder(orderId);
return Map.of(
"status", 200,
"orderId", order.getId().value(),
"orderStatus", order.getStatus().name()
);
} catch (IllegalStateException e) {
return Map.of("status", 409, "error", e.getMessage());
}
}
public record CreateOrderRequest(String customerId,
List<ItemRequest> items) {}
public record ItemRequest(String productId, String name,
BigDecimal price, int quantity) {}
}Driven Adapters
java
package com.example.orders.adapter.outbound.persistence;
import com.example.orders.domain.model.Order;
import com.example.orders.domain.model.OrderId;
import com.example.orders.port.outbound.OrderRepository;
import java.util.Map;
import java.util.Optional;
import java.util.concurrent.ConcurrentHashMap;
// In-memory adapter — easy to swap for JPA, DynamoDB, etc.
public class InMemoryOrderRepository implements OrderRepository {
private final Map<String, Order> store = new ConcurrentHashMap<>();
@Override
public Order save(Order order) {
store.put(order.getId().value(), order);
return order;
}
@Override
public Optional<Order> findById(OrderId id) {
return Optional.ofNullable(store.get(id.value()));
}
@Override
public void delete(OrderId id) {
store.remove(id.value());
}
}java
package com.example.orders.adapter.outbound.payment;
import com.example.orders.port.outbound.PaymentGateway;
import com.example.orders.port.outbound.PaymentResult;
import java.math.BigDecimal;
import java.util.UUID;
public class StripePaymentAdapter implements PaymentGateway {
@Override
public PaymentResult charge(String customerId, BigDecimal amount) {
// In production: call Stripe API via HTTP client
System.out.printf("[Stripe] Charging customer %s: $%s%n",
customerId, amount);
// Simulate success
return new PaymentResult(
true,
"txn_" + UUID.randomUUID().toString().substring(0, 8),
null
);
}
}java
package com.example.orders.adapter.outbound.notification;
import com.example.orders.port.outbound.NotificationSender;
public class SesNotificationAdapter implements NotificationSender {
@Override
public void sendOrderConfirmation(String customerId, String orderId) {
// In production: call AWS SES SDK
System.out.printf("[SES] Order confirmation sent to %s for order %s%n",
customerId, orderId);
}
@Override
public void sendOrderCancellation(String customerId, String orderId) {
System.out.printf("[SES] Order cancellation sent to %s for order %s%n",
customerId, orderId);
}
}Wiring It All Together
java
package com.example.orders;
import com.example.orders.adapter.inbound.rest.OrderRestController;
import com.example.orders.adapter.outbound.notification.SesNotificationAdapter;
import com.example.orders.adapter.outbound.payment.StripePaymentAdapter;
import com.example.orders.adapter.outbound.persistence.InMemoryOrderRepository;
import com.example.orders.application.OrderApplicationService;
import com.example.orders.port.outbound.*;
import java.math.BigDecimal;
import java.util.List;
import java.util.Map;
public class HexagonalApp {
public static void main(String[] args) {
// 1. Create driven adapters (outbound)
OrderRepository repository = new InMemoryOrderRepository();
PaymentGateway payment = new StripePaymentAdapter();
NotificationSender notifications = new SesNotificationAdapter();
// 2. Create application service (implements driving ports)
OrderApplicationService appService =
new OrderApplicationService(repository, payment, notifications);
// 3. Create driving adapter (inbound)
OrderRestController controller =
new OrderRestController(appService, appService, appService);
// 4. Simulate HTTP requests
System.out.println("=== Creating Order ===");
var createResponse = controller.handleCreateOrder(
new OrderRestController.CreateOrderRequest(
"customer-42",
List.of(
new OrderRestController.ItemRequest(
"prod-1", "Mechanical Keyboard",
new BigDecimal("149.99"), 1),
new OrderRestController.ItemRequest(
"prod-2", "USB-C Cable",
new BigDecimal("12.99"), 3)
)
)
);
System.out.println("Response: " + createResponse);
String orderId = (String) createResponse.get("orderId");
System.out.println("\n=== Getting Order ===");
Map<String, Object> getResponse = controller.handleGetOrder(orderId);
System.out.println("Response: " + getResponse);
System.out.println("\n=== Cancelling Order ===");
Map<String, Object> cancelResponse = controller.handleCancelOrder(orderId);
System.out.println("Response: " + cancelResponse);
System.out.println("\n=== Attempting Double Cancel ===");
Map<String, Object> doubleCancelResponse =
controller.handleCancelOrder(orderId);
System.out.println("Response: " + doubleCancelResponse);
}
}Testing with Hexagonal Architecture
One of the greatest benefits is testability. The domain and application services can be tested in complete isolation using simple test doubles.
java
package com.example.orders;
import com.example.orders.application.OrderApplicationService;
import com.example.orders.domain.model.*;
import com.example.orders.port.outbound.*;
import java.math.BigDecimal;
import java.util.*;
public class OrderServiceTest {
// Simple in-memory test doubles — no mocking framework needed
static class FakeOrderRepository implements OrderRepository {
private final Map<String, Order> store = new HashMap<>();
public Order save(Order order) {
store.put(order.getId().value(), order);
return order;
}
public Optional<Order> findById(OrderId id) {
return Optional.ofNullable(store.get(id.value()));
}
public void delete(OrderId id) { store.remove(id.value()); }
public int size() { return store.size(); }
}
static class FakePaymentGateway implements PaymentGateway {
boolean shouldFail = false;
int chargeCount = 0;
public PaymentResult charge(String customerId, BigDecimal amount) {
chargeCount++;
if (shouldFail) {
return new PaymentResult(false, null, "Insufficient funds");
}
return new PaymentResult(true, "txn_test_123", null);
}
}
static class FakeNotificationSender implements NotificationSender {
List<String> confirmations = new ArrayList<>();
List<String> cancellations = new ArrayList<>();
public void sendOrderConfirmation(String customerId, String orderId) {
confirmations.add(orderId);
}
public void sendOrderCancellation(String customerId, String orderId) {
cancellations.add(orderId);
}
}
public static void main(String[] args) {
testCreateOrderSuccess();
testCreateOrderPaymentFailure();
testCancelOrder();
System.out.println("\n✅ All tests passed!");
}
static void testCreateOrderSuccess() {
var repo = new FakeOrderRepository();
var payment = new FakePaymentGateway();
var notifier = new FakeNotificationSender();
var service = new OrderApplicationService(repo, payment, notifier);
Order order = service.createOrder("cust-1", List.of(
new OrderItem("p1", "Widget", new BigDecimal("25.00"), 2)
));
assert order.getStatus() == OrderStatus.CONFIRMED
: "Order should be CONFIRMED";
assert order.getTotalAmount().compareTo(new BigDecimal("50.00")) == 0
: "Total should be 50.00";
assert repo.size() == 1 : "Repository should have 1 order";
assert payment.chargeCount == 1 : "Payment should be charged once";
assert notifier.confirmations.size() == 1
: "One confirmation should be sent";
System.out.println("✓ testCreateOrderSuccess");
}
static void testCreateOrderPaymentFailure() {
var repo = new FakeOrderRepository();
var payment = new FakePaymentGateway();
payment.shouldFail = true;
var notifier = new FakeNotificationSender();
var service = new OrderApplicationService(repo, payment, notifier);
try {
service.createOrder("cust-1", List.of(
new OrderItem("p1", "Widget", new BigDecimal("25.00"), 1)
));
assert false : "Should have thrown exception";
} catch (RuntimeException e) {
assert e.getMessage().contains("Payment failed")
: "Should indicate payment failure";
}
assert repo.size() == 0 : "Nothing should be persisted";
assert notifier.confirmations.isEmpty()
: "No notifications should be sent";
System.out.println("✓ testCreateOrderPaymentFailure");
}
static void testCancelOrder() {
var repo = new FakeOrderRepository();
var payment = new FakePaymentGateway();
var notifier = new FakeNotificationSender();
var service = new OrderApplicationService(repo, payment, notifier);
Order order = service.createOrder("cust-1", List.of(
new OrderItem("p1", "Widget", new BigDecimal("10.00"), 1)
));
Order cancelled = service.cancelOrder(order.getId().value());
assert cancelled.getStatus() == OrderStatus.CANCELLED
: "Order should be CANCELLED";
assert notifier.cancellations.size() == 1
: "Cancellation notification should be sent";
System.out.println("✓ testCancelOrder");
}
}Hexagonal vs. Other Architectures
| Aspect | Layered | Hexagonal | Clean |
|---|---|---|---|
| Dependency direction | Top-down | Outside-in | Outside-in |
| Domain isolation | Weak | Strong | Strong |
| Testability | Moderate | Excellent | Excellent |
| Framework coupling | High | None | None |
| Complexity | Low | Medium | High |
| Best for | Simple CRUD | Service-oriented apps | Complex enterprise |
Swapping Adapters: DynamoDB Example
The power of hexagonal architecture is demonstrated when swapping infrastructure. Here we replace the in-memory repository with DynamoDB — the domain and application layers remain untouched.
java
package com.example.orders.adapter.outbound.persistence;
import com.example.orders.domain.model.*;
import com.example.orders.port.outbound.OrderRepository;
import software.amazon.awssdk.services.dynamodb.DynamoDbClient;
import software.amazon.awssdk.services.dynamodb.model.*;
import java.math.BigDecimal;
import java.util.*;
public class DynamoDbOrderRepository implements OrderRepository {
private static final String TABLE_NAME = "Orders";
private final DynamoDbClient dynamoDbClient;
public DynamoDbOrderRepository(DynamoDbClient dynamoDbClient) {
this.dynamoDbClient = dynamoDbClient;
}
@Override
public Order save(Order order) {
Map<String, AttributeValue> item = Map.of(
"PK", AttributeValue.builder().s(order.getId().value()).build(),
"customerId", AttributeValue.builder()
.s(order.getCustomerId()).build(),
"status", AttributeValue.builder()
.s(order.getStatus().name()).build(),
"totalAmount", AttributeValue.builder()
.n(order.getTotalAmount().toPlainString()).build(),
"createdAt", AttributeValue.builder()
.s(order.getCreatedAt().toString()).build()
);
dynamoDbClient.putItem(PutItemRequest.builder()
.tableName(TABLE_NAME)
.item(item)
.build());
return order;
}
@Override
public Optional<Order> findById(OrderId id) {
GetItemResponse response = dynamoDbClient.getItem(
GetItemRequest.builder()
.tableName(TABLE_NAME)
.key(Map.of("PK",
AttributeValue.builder().s(id.value()).build()))
.build());
if (!response.hasItem()) {
return Optional.empty();
}
// Map back to domain object (simplified)
Map<String, AttributeValue> item = response.item();
// In production: full deserialization including items list
return Optional.empty(); // Placeholder for brevity
}
@Override
public void delete(OrderId id) {
dynamoDbClient.deleteItem(DeleteItemRequest.builder()
.tableName(TABLE_NAME)
.key(Map.of("PK",
AttributeValue.builder().s(id.value()).build()))
.build());
}
}When to Use (and When Not To)
Best Practices
Domain purity above all: The domain model must never import framework classes, infrastructure packages, or annotation processors. If you see
@Entityor@Autowiredin your domain, the boundary is violated.Name ports after domain intent: Use names like
OrderRepositoryandNotificationSenderrather thanJpaDaoorSqsPublisher. The port name should describe what is needed, not how it's implemented.One port per use case for driving ports: Favor fine-grained interfaces (
CreateOrderUseCase,CancelOrderUseCase) over a single bloatedOrderServiceinterface. This aligns with the Interface Segregation Principle.Use the application service as the composition root: The application service orchestrates ports and domain objects. Keep it thin — it should delegate logic to the domain model, not contain business rules.
Test the domain without any adapter: Write unit tests for domain objects using only plain Java. Use fake adapters (not mocks) for integration tests of application services.
Enforce boundaries with module structure: Use Java modules (
module-info.java), separate Gradle subprojects, or ArchUnit tests to prevent accidental coupling between the domain and infrastructure.Start simple, grow into hexagonal: Don't apply hexagonal architecture to a simple CRUD app. Introduce it when you recognize that domain complexity or infrastructure variability justifies the boundary enforcement.
Keep adapters as thin translators: Adapters should convert between external representations and domain objects. Business logic must never live in an adapter.
Prefer constructor injection for adapter wiring: Pass ports through constructors to make dependencies explicit and enable easy testing without dependency injection frameworks.
Document the port contract: Add Javadoc to port interfaces describing preconditions, postconditions, and exception semantics. The port is the primary API of your application.
Related Concepts
- SOLID Principles — Interface Segregation: Driving ports benefit from segregated interfaces per use case
- SOLID Principles — Dependency Inversion: Hexagonal architecture is a direct application of DIP
- SOLID Principles — Single Responsibility: Adapters each have a single responsibility
- SOLID Principles — Open/Closed Principle: New adapters extend behavior without modifying the core
- Serverless and Containers: Hexagonal architecture simplifies deploying the same core to Lambda or ECS
- REST HTTP Verbs and Status Codes: Driving adapters translate REST conventions into use case calls