Hexagonal Architecture — Ports, Adapters, and Testability
A complete guide to Hexagonal Architecture (Ports and Adapters): structure applications so domain logic is isolated from frameworks, databases, and external services.
Note: This guide follows English-language naming conventions and terminology standards common in international development teams. Examples use English identifiers and comments to maximize compatibility across codebases and tooling.
Overview
Hexagonal Architecture, also known as Ports and Adapters, is a design pattern that isolates the core domain logic from external concerns like frameworks, databases, and UI. Instead of the domain depending on infrastructure, infrastructure depends on the domain through well-defined interfaces called ports. This inversion of dependencies makes applications easier to test, refactor, and adapt to changing requirements.
When to Use
-
For alternatives, see Onion Architecture — Dependency Inversion in Practice.
-
You need to swap frameworks (web, CLI, messaging) without touching business logic
-
You want fast, isolated unit tests without mocking external services
-
Your application integrates with multiple external systems (databases, APIs, queues)
-
You are migrating from a monolith and need clear boundaries
Core Concepts
Ports
Ports are interfaces that define what the application needs from the outside world, or what it offers to the outside world. They belong to the domain layer.
Adapters
Adapters are concrete implementations of ports. They translate between the application’s domain and external technologies (HTTP, SQL, message queues).
Domain (Inside)
The application’s core logic — entities, value objects, use cases, and domain services. It has zero external dependencies.
Structure
┌─────────────────────────────────────┐
│ Adapters (Outside) │
│ ┌─────────┐ ┌─────────┐ ┌────────┐ │
│ │ Web API │ │ CLI │ │ Events │ │
│ └────┬────┘ └────┬────┘ └───┬────┘ │
│ │ │ │ │
│ ┌────┴───────────┴──────────┴────┐ │
│ │ Primary Ports │ │
│ │ (Driving Adapters) │ │
│ └──────────────┬──────────────────┘ │
│ │ │
│ ┌──────────────┴──────────────────┐ │
│ │ Application │ │
│ │ (Use Cases) │ │
│ └──────────────┬──────────────────┘ │
│ │ │
│ ┌──────────────┴──────────────────┐ │
│ │ Secondary Ports │ │
│ │ (Driven Adapters) │ │
│ └──────────────┬──────────────────┘ │
│ │ │ │ │
│ ┌────┴────┐ ┌───┴───┐ ┌─────┴────┐│
│ │ Database│ │External│ │ Queue ││
│ │ Adapter │ │ API │ │ Adapter ││
│ └─────────┘ └────────┘ └──────────┘│
└─────────────────────────────────────┘
Implementation
Define the Port
// Secondary port (driven) — what the domain needs
public interface OrderRepository {
Order findById(OrderId id);
void save(Order order);
}
// Primary port (driving) — what the domain offers
public interface PlaceOrderUseCase {
OrderResult place(PlaceOrderCommand command);
}
Implement the Domain
public class PlaceOrderService implements PlaceOrderUseCase {
private final OrderRepository repository;
private final PaymentGatewayPort paymentPort;
public PlaceOrderService(OrderRepository repository, PaymentGatewayPort paymentPort) {
this.repository = repository;
this.paymentPort = paymentPort;
}
@Override
public OrderResult place(PlaceOrderCommand command) {
Order order = Order.create(command);
PaymentResult payment = paymentPort.charge(order.total());
if (payment.success()) {
order.confirm(payment.transactionId());
repository.save(order);
return OrderResult.success(order.id());
}
return OrderResult.failure(payment.error());
}
}
Create the Adapter
@RestController
@RequestMapping("/orders")
public class OrderControllerAdapter {
private final PlaceOrderUseCase useCase;
public OrderControllerAdapter(PlaceOrderUseCase useCase) {
this.useCase = useCase;
}
@PostMapping
public ResponseEntity<OrderResponse> place(@RequestBody PlaceOrderRequest request) {
PlaceOrderCommand command = request.toCommand();
OrderResult result = useCase.place(command);
return result.isSuccess()
? ResponseEntity.ok(OrderResponse.from(result))
: ResponseEntity.badRequest().body(OrderResponse.error(result));
}
}
Testing Strategy
| Test Type | What It Tests | Dependencies |
|---|---|---|
| Unit | Domain logic | None (pure Java) |
| Integration | Adapter + real DB | Testcontainers |
| Contract | Port boundary | In-memory stub |
| E2E | Full flow | Everything |
Common Mistakes
- Leaking framework annotations into the domain — keep
@Entity,@Autowired, and similar out - Anemic domain models — ports should expose behavior, not just data access
- Over-engineering simple CRUD — hexagonal architecture adds ceremony; use it when the domain justifies it
Detailed Scenario: Order System with Hexagonal Architecture
Project: Order system Java 21 + Spring Boot
Ports defined:
Primary (driving):
- PlaceOrderUseCase: place(PlaceOrderCommand) -> OrderResult
- CancelOrderUseCase: cancel(CancelOrderCommand) -> void
- GetOrderQuery: getById(OrderId) -> OrderDto
Secondary (driven):
- OrderRepository: findById, save, update
- PaymentGatewayPort: charge(Money) -> PaymentResult
- NotificationPort: sendOrderConfirmation(OrderId, Email)
- InventoryPort: reserveItems(List<OrderItem>) -> ReservationId
Adapters implemented:
Driving:
- RestOrderController (Spring @RestController)
- GrpcOrderService (gRPC service)
- CliOrderHandler (Picocli CLI)
- KafkaOrderConsumer (event consumer)
Driven:
- PostgresOrderRepository (JPA/Hibernate)
- StripePaymentAdapter (HTTP client)
- SmtpNotificationAdapter (JavaMail)
- RedisInventoryAdapter (Redis client)
Flow: Place Order via REST
1. POST /orders -> RestOrderController
2. Controller maps request to PlaceOrderCommand
3. Calls PlaceOrderUseCase.place(command)
4. PlaceOrderService:
a. Creates Order.create(command)
b. InventoryPort.reserveItems(items)
c. PaymentGatewayPort.charge(order.total())
d. If payment ok: order.confirm(txId), OrderRepository.save(order)
e. NotificationPort.sendOrderConfirmation(order.id, email)
f. Returns OrderResult.success(order.id)
5. Controller maps OrderResult to OrderResponse
Testing with in-memory adapters:
public class PlaceOrderServiceTest {
private InMemoryOrderRepository repo = new InMemoryOrderRepository();
private FakePaymentGateway payment = new FakePaymentGateway();
private SpyNotificationPort notification = new SpyNotificationPort();
private FakeInventoryPort inventory = new FakeInventoryPort();
private PlaceOrderService service;
@BeforeEach
void setUp() {
service = new PlaceOrderService(repo, payment, notification, inventory);
}
@Test
void shouldPlaceOrderWhenPaymentSucceeds() {
payment.setSuccess(true);
var cmd = new PlaceOrderCommand("cust-1", List.of(new Item("prod-1", 2)));
var result = service.place(cmd);
assertTrue(result.isSuccess());
assertEquals(1, notification.sentConfirmations());
assertTrue(repo.findById(result.orderId()).isPresent());
}
@Test
void shouldFailWhenPaymentDeclines() {
payment.setSuccess(false);
var cmd = new PlaceOrderCommand("cust-1", List.of(new Item("prod-1", 2)));
var result = service.place(cmd);
assertFalse(result.isSuccess());
assertEquals(0, notification.sentConfirmations());
}
}
// Tests run in < 100ms without DB or network
// Domain coverage: 95%+
// Adapters tested separately with Testcontainers
How do I handle dependency wiring in hexagonal?
Use dependency injection at the entry point (Application.java in Spring Boot). The domain does not know Spring. Adapters are annotated with @RestController, @Repository, @Service. Wiring happens in a configuration class: @Bean PlaceOrderUseCase with concrete adapters. For testing, simply construct the service with in-memory adapters using new. The domain never depends on the DI container.
Variants
- Onion Architecture — adds explicit domain services and application services layers
- Clean Architecture — emphasizes the Dependency Rule: dependencies point inward
- BCE (Boundary-Control-Entity) — similar structure with different naming
Troubleshooting
- High latency between services: trace the request path. Look for synchronous chains, missing caching, and oversized payloads that cross network boundaries.
- Single point of failure: identify components without redundancy. Add replicas, failover, or circuit breakers before scaling traffic.
- Unexpected coupling between services: review shared databases, libraries, and schemas. Bound contexts should own their data and expose stable interfaces.
- Cost spikes after scaling: right-size instances and use autoscaling with limits. Reserved capacity or spot instances can reduce steady-state spend.
- Difficult to reason about the system: maintain architecture decision records and service dependency maps. Use observability to validate the diagrams.
FAQ
How is Hexagonal different from Clean Architecture? Hexagonal focuses on the ports-and-adapters metaphor. Clean Architecture adds the explicit layer dependency rule and emphasizes the Entities layer. Both achieve the same goal.
Do I need DDD to use Hexagonal? No. You can use simple entities and value objects. DDD complements hexagonal but is not required.
When should I NOT use Hexagonal? Simple CRUD applications, prototypes, or scripts where the extra structure does not provide value.
How do I get started with this in an existing project?
Start with a small, isolated part of your codebase. Apply the concepts from this guide to one module or service. Measure the impact, then expand to other areas.
What tools do I need?
The tools mentioned throughout this guide are listed in each section. Most are open-source and widely adopted. Check the related resources for setup instructions.
How do I measure success after implementing this?
Define clear metrics before starting: performance benchmarks, error rates, or maintainability indicators. Compare before and after. Iterate based on the data, not on assumptions.
End of document. Review and update quarterly.
Common Production Pitfalls
- Treating the guide as a checklist to complete once rather than a practice to evolve.
- Adopting every recommendation at once instead of starting with one measured change.
- Skipping the maturity assessment and forcing advanced practices on an unprepared team.
- Not updating runbooks and on-call expectations as new practices are introduced.
- Ignoring real incident data when prioritizing which parts of the guide to apply first.
- Failing to assign an owner who reviews decisions quarterly.
- Copying examples without adapting them to the team’s actual tooling and constraints.
- Forgetting to measure outcomes before adding the next improvement.
Related Resources
Clean Architecture
A practical guide to Uncle Bob's Clean Architecture: organize code into layers so that frameworks, UI, and databases are details, not dependencies.
GuideOnion Architecture — Dependency Inversion in Practice
A practical guide to Onion Architecture: organizing code around the domain model, enforcing dependency direction inward, and isolating infrastructure from business logic.
GuideModular Monolith — A Pragmatic Architecture
A practical guide to Modular Monoliths: combine the simplicity of monoliths with the modularity of microservices through clear bounded contexts and strict module boundaries.