Skip to content

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.java

Domain 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

AspectLayeredHexagonalClean
Dependency directionTop-downOutside-inOutside-in
Domain isolationWeakStrongStrong
TestabilityModerateExcellentExcellent
Framework couplingHighNoneNone
ComplexityLowMediumHigh
Best forSimple CRUDService-oriented appsComplex 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

  1. Domain purity above all: The domain model must never import framework classes, infrastructure packages, or annotation processors. If you see @Entity or @Autowired in your domain, the boundary is violated.

  2. Name ports after domain intent: Use names like OrderRepository and NotificationSender rather than JpaDao or SqsPublisher. The port name should describe what is needed, not how it's implemented.

  3. One port per use case for driving ports: Favor fine-grained interfaces (CreateOrderUseCase, CancelOrderUseCase) over a single bloated OrderService interface. This aligns with the Interface Segregation Principle.

  4. 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.

  5. 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.

  6. 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.

  7. 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.

  8. Keep adapters as thin translators: Adapters should convert between external representations and domain objects. Business logic must never live in an adapter.

  9. Prefer constructor injection for adapter wiring: Pass ports through constructors to make dependencies explicit and enable easy testing without dependency injection frameworks.

  10. Document the port contract: Add Javadoc to port interfaces describing preconditions, postconditions, and exception semantics. The port is the primary API of your application.