Hexagonal Architecture: Ports, Adapters, and the Core That Never Changes
Hexagonal architecture (ports and adapters) puts your domain at the center and keeps infrastructure at the edge. Here's how it works, why testability improves dramatically, and how to implement it practically.

James Ross Jr.
Strategic Systems Architect & Enterprise Software Developer
The Origin and the Name
Hexagonal architecture was introduced by Alistair Cockburn in 2005. The hexagon in the name is not meaningful — Cockburn chose a hexagonal shape because it's visually distinct and has enough sides to show multiple ports around the perimeter. It could just as easily be called "octagonal architecture" and mean the same thing.
What is meaningful is the core insight: your application's domain logic should be at the center, entirely independent of the infrastructure that surrounds it. Databases, web frameworks, message queues, external APIs — these are all interchangeable details at the edge of the system. The domain core should be able to function without knowing what those details are.
This insight is shared with clean architecture and onion architecture. Hexagonal architecture makes it concrete through two specific concepts: ports and adapters.
Ports: What Your Application Needs and Provides
A port is an interface that defines a communication boundary.
There are two kinds of ports:
Primary ports (driving ports): These are how the outside world interacts with your application. An HTTP controller driving a use case. A CLI command triggering a domain operation. A scheduled job triggering a business process. The primary port is the entry point — it drives the application.
Secondary ports (driven ports): These are interfaces your application uses to interact with the outside world. OrderRepository (your application drives the database). EmailService (your application drives the email provider). PaymentGateway (your application drives the payment processor). Secondary ports are defined by the application domain — they specify exactly what the domain needs, in domain terms.
The critical distinction: primary ports face inward (the world drives your app). Secondary ports face outward (your app drives the world), but they are defined by the domain, not by the infrastructure.
Adapters: The Pluggable Implementations
An adapter is a concrete implementation of a port that translates between the domain's language and the infrastructure's language.
For a secondary port like OrderRepository, you might have:
PrismaOrderRepository— implementsOrderRepositoryusing Prisma and PostgreSQLInMemoryOrderRepository— implementsOrderRepositoryusing an in-memory Map (for tests)DynamoOrderRepository— implementsOrderRepositoryusing AWS DynamoDB
Each adapter translates between what the domain needs (save an Order domain object) and what the infrastructure provides (upsert a row in a PostgreSQL table). The domain calls orderRepo.save(order) and doesn't know — or care — which adapter is behind the interface.
For a primary port driven by HTTP:
ExpressOrderController— adapts HTTP requests to use case method callsGraphQLOrderResolver— adapts GraphQL queries/mutations to the same use caseCLIOrderCommand— adapts command-line input to the same use case
The same domain use case (CreateOrderUseCase) can be driven by any primary adapter. The use case doesn't know it's handling an HTTP request vs a CLI command.
The Practical Structure
Let me show what this looks like in a real project directory structure:
src/
├── domain/
│ ├── order.ts ← Order entity and business rules
│ ├── orderItem.ts
│ ├── money.ts
│ └── ports/
│ ├── orderRepository.ts ← interface (secondary port)
│ └── paymentGateway.ts ← interface (secondary port)
│
├── application/
│ ├── createOrder.ts ← use case (primary port implementation target)
│ ├── submitOrder.ts
│ └── cancelOrder.ts
│
├── adapters/
│ ├── driving/ ← primary adapters
│ │ ├── http/
│ │ │ └── orderController.ts
│ │ └── cli/
│ │ └── orderCommand.ts
│ └── driven/ ← secondary adapters
│ ├── persistence/
│ │ ├── prismaOrderRepository.ts
│ │ └── inMemoryOrderRepository.ts
│ └── payments/
│ ├── stripePaymentGateway.ts
│ └── mockPaymentGateway.ts
│
└── main.ts ← composition root (wires everything together)
The domain and application layers have no imports from the adapters layer. They only import from each other. The adapters layer imports from the domain (to know what interfaces to implement) but the domain never imports from adapters. This is the dependency rule enforced in code.
The Testability Benefit: Why This Changes Everything
The practical reason to adopt hexagonal architecture is that it makes testing dramatically better.
Without hexagonal architecture, a test of business logic typically requires:
- A running database (because the domain uses the ORM directly)
- A running web server (because the logic is in the route handler)
- Real HTTP requests (because the framework is woven into the logic)
- Elaborate setup and teardown
With hexagonal architecture, a test of business logic requires:
- An
InMemoryOrderRepositorythat you control completely - Direct instantiation of the use case with the test repository
- Method calls instead of HTTP requests
- No external dependencies
// Test for CreateOrderUseCase — no database, no HTTP, no framework
describe('CreateOrderUseCase', () => {
let orderRepo: InMemoryOrderRepository
let productRepo: InMemoryProductRepository
let useCase: CreateOrderUseCase
beforeEach(() => {
orderRepo = new InMemoryOrderRepository()
productRepo = new InMemoryProductRepository()
productRepo.add(new Product('prod-1', 'Widget', Money.of(29.99)))
useCase = new CreateOrderUseCase(orderRepo, productRepo)
})
it('creates an order with valid items', async () => {
const orderId = await useCase.execute({
customerId: 'cust-1',
items: [{ productId: 'prod-1', quantity: 2 }]
})
const order = await orderRepo.findById(orderId)
expect(order).toBeDefined()
expect(order!.getTotal()).toEqual(Money.of(59.98))
})
it('throws when product does not exist', async () => {
await expect(useCase.execute({
customerId: 'cust-1',
items: [{ productId: 'nonexistent', quantity: 1 }]
})).rejects.toThrow('Product nonexistent not found')
})
})
These tests run in milliseconds. They're deterministic. They test business rules in isolation. When they fail, the failure is in the business logic — not in database connectivity or HTTP parsing.
The adapter tests are separate:
PrismaOrderRepositoryintegration tests run against a real database (or test container)ExpressOrderControllertests run with a real HTTP server- These tests are slower and fewer in number, focused on verifying the integration layer
This separation is one of the most valuable structural properties of hexagonal architecture.
Handling Cross-Cutting Concerns
Hexagonal architecture often raises a question: where does transaction management go? Authorization? Logging?
Transactions typically belong in the use case or in a decorator/wrapper around the secondary ports. The use case orchestrates a business operation; the transaction scope wraps that operation. Some teams use a Unit of Work pattern that encapsulates the transaction management outside the individual repository calls.
Authorization belongs at the primary port level or as a use case guard. The controller can verify coarse-grained permissions before invoking the use case. The use case can enforce fine-grained business rules.
Logging and observability are typically cross-cutting and implemented via decorators that wrap port implementations, or through domain events that observers can subscribe to.
When Hexagonal Architecture Pays Off
The overhead of hexagonal architecture — defining ports, implementing multiple adapters, managing the composition root — pays off when:
- Your domain has complex business rules that need thorough unit testing in isolation
- The system will need to support multiple delivery mechanisms (HTTP API, GraphQL, CLI, event consumer)
- Infrastructure choices might change (switching databases, adding a cache, changing payment providers)
- The team has the discipline to enforce the dependency rule consistently
It is overkill for simple CRUD applications with minimal business logic. A system that mostly stores and retrieves data without meaningful business rules has little domain logic to protect and little benefit from isolating it.
Apply the pattern where it earns its complexity. Skip it where it doesn't.
Hexagonal architecture is one of my default starting points for systems with meaningful business logic. The testability it provides and the freedom from infrastructure lock-in are real, lasting benefits.
If you're implementing hexagonal architecture or evaluating whether it fits your domain, I'd be glad to work through the specifics with you.