Hexagonal Architecture: Ports and Adapters Pattern

Hexagonal Architecture: Ports and Adapters Pattern
Hexagonal Architecture, also called "Ports and Adapters," was introduced by Alistair Cockburn in 2005. The central idea is simple but powerful: your application's core domain (business logic) should be completely isolated from the outside world — databases, HTTP, file systems, message queues — by defining explicit interfaces (ports) at the boundary.
Anything that wants to talk to your application must go through a port. The implementation of how it communicates is an adapter. The domain never depends on any adapter directly.
The Core Problem It Solves
In a traditional layered architecture, business logic often gets coupled to infrastructure details:
// ⌠Business logic coupled to infrastructure
export class OrderService {
async createOrder(dto: CreateOrderDto) {
// Directly coupled to PostgreSQL — can't test without a database
const product = await this.db.query(
'SELECT * FROM products WHERE id = $1', [dto.productId]
);
// Directly coupled to HTTP — can't call from a message queue handler
const tax = await fetch(`https://tax-api.com/calculate?country=${dto.country}`);
// Directly coupled to SendGrid — can't swap to another provider
await this.sendgrid.send({ to: dto.email, subject: 'Order confirmed' });
}
}If you want to test this service, you need a real database, a real HTTP connection, and a real SendGrid account. If you want to change the database, you need to rewrite business logic. The domain is entangled with infrastructure.
Hexagonal Architecture separates them completely.
The Hexagonal Structure
Driving Adapters
(HTTP, CLI, Test Runner)
│
│ (calls)
â–¼
┌─────────────────────â”
│ Input Ports │
│ (Interfaces the │
│ app exposes) │
│ │
│ DOMAIN │
│ (Business Logic) │
│ │
│ Output Ports │
│ (Interfaces the │
│ domain requires) │
└─────────────────────┘
│
│ (calls)
â–¼
Driven Adapters
(DB, Email, External APIs)The Hexagon (Domain)
The hexagon contains pure business logic with zero dependencies on external frameworks or infrastructure:
// domain/Order.ts — pure domain model, no imports from infrastructure
export class Order {
private items: OrderItem[] = [];
private status: OrderStatus = OrderStatus.Draft;
addItem(productId: string, quantity: number, unitPrice: Money): void {
if (this.status !== OrderStatus.Draft) {
throw new OrderNotEditableError();
}
if (quantity <= 0) {
throw new InvalidQuantityError(quantity);
}
this.items.push(new OrderItem(productId, quantity, unitPrice));
}
confirm(): void {
if (this.items.length === 0) {
throw new EmptyOrderError();
}
this.status = OrderStatus.Confirmed;
}
total(): Money {
return this.items.reduce(
(sum, item) => sum.add(item.subtotal()),
Money.zero()
);
}
}No SQL. No HTTP. No email library. Pure business rules.
Input Ports (Primary Ports)
Input ports are interfaces that define what the application can do — the use cases. They are driven by external actors (HTTP requests, CLI commands, test runners).
// ports/input/OrderUseCase.ts
export interface CreateOrderUseCase {
createOrder(command: CreateOrderCommand): Promise<OrderId>;
}
export interface GetOrderUseCase {
getOrder(query: GetOrderQuery): Promise<OrderDto>;
}
export interface ConfirmOrderUseCase {
confirmOrder(command: ConfirmOrderCommand): Promise<void>;
}
// Command objects (carry input data)
export interface CreateOrderCommand {
customerId: string;
items: Array<{ productId: string; quantity: number }>;
}
export interface ConfirmOrderCommand {
orderId: string;
requestingUserId: string;
}Application Service (Implements Input Ports)
// application/OrderApplicationService.ts
import { CreateOrderUseCase, CreateOrderCommand } from '../ports/input/OrderUseCase';
import { OrderRepository } from '../ports/output/OrderRepository';
import { ProductCatalog } from '../ports/output/ProductCatalog';
import { NotificationService } from '../ports/output/NotificationService';
export class OrderApplicationService implements CreateOrderUseCase {
constructor(
private orderRepository: OrderRepository, // Output port
private productCatalog: ProductCatalog, // Output port
private notificationService: NotificationService, // Output port
) {}
async createOrder(command: CreateOrderCommand): Promise<OrderId> {
// Fetch product details through output port
const products = await this.productCatalog.findByIds(
command.items.map(i => i.productId)
);
// Create domain object with pure business logic
const order = new Order(command.customerId);
for (const item of command.items) {
const product = products.find(p => p.id === item.productId);
if (!product) throw new ProductNotFoundError(item.productId);
order.addItem(item.productId, item.quantity, product.price);
}
// Persist through output port
const savedOrder = await this.orderRepository.save(order);
// Notify through output port
await this.notificationService.sendOrderCreatedEmail(
command.customerId,
savedOrder.id
);
return savedOrder.id;
}
}Output Ports (Secondary Ports)
Output ports are interfaces the domain needs from the outside world. They define what the application requires without specifying how it is provided.
// ports/output/OrderRepository.ts
export interface OrderRepository {
save(order: Order): Promise<SavedOrder>;
findById(id: OrderId): Promise<Order | null>;
findByCustomerId(customerId: string): Promise<Order[]>;
}
// ports/output/ProductCatalog.ts
export interface ProductCatalog {
findByIds(ids: string[]): Promise<Product[]>;
isInStock(productId: string, quantity: number): Promise<boolean>;
}
// ports/output/NotificationService.ts
export interface NotificationService {
sendOrderCreatedEmail(customerId: string, orderId: OrderId): Promise<void>;
sendOrderShippedEmail(customerId: string, trackingNumber: string): Promise<void>;
}The domain depends only on these interfaces — never on PostgreSQL, SendGrid, or any specific implementation.
Adapters
Adapters implement the ports. They bridge the gap between the domain's abstract interfaces and concrete external systems.
Database Adapter (Driven)
// adapters/output/PostgreSQLOrderRepository.ts
import { Pool } from 'pg';
import { OrderRepository } from '../../ports/output/OrderRepository';
import { Order, SavedOrder } from '../../domain/Order';
export class PostgreSQLOrderRepository implements OrderRepository {
constructor(private db: Pool) {}
async save(order: Order): Promise<SavedOrder> {
const client = await this.db.connect();
try {
await client.query('BEGIN');
const orderResult = await client.query(
'INSERT INTO orders (customer_id, status, created_at) VALUES ($1, $2, NOW()) RETURNING id',
[order.customerId, order.status]
);
const orderId = orderResult.rows[0].id;
for (const item of order.items) {
await client.query(
'INSERT INTO order_items (order_id, product_id, quantity, unit_price) VALUES ($1, $2, $3, $4)',
[orderId, item.productId, item.quantity, item.unitPrice.amount]
);
}
await client.query('COMMIT');
return { ...order, id: orderId };
} catch (e) {
await client.query('ROLLBACK');
throw e;
} finally {
client.release();
}
}
async findById(id: OrderId): Promise<Order | null> {
const result = await this.db.query(
'SELECT o.*, oi.* FROM orders o LEFT JOIN order_items oi ON o.id = oi.order_id WHERE o.id = $1',
[id]
);
if (result.rows.length === 0) return null;
return this.mapToOrder(result.rows);
}
}HTTP Adapter (Driving)
// adapters/input/HTTPOrderController.ts
import { Router, Request, Response } from 'express';
import { CreateOrderUseCase } from '../../ports/input/OrderUseCase';
export class HTTPOrderController {
constructor(
private createOrderUseCase: CreateOrderUseCase,
) {}
routes(): Router {
const router = Router();
router.post('/orders', this.create.bind(this));
return router;
}
private async create(req: Request, res: Response): Promise<void> {
const { customerId, items } = req.body;
try {
const orderId = await this.createOrderUseCase.createOrder({
customerId,
items,
});
res.status(201).json({ orderId });
} catch (err) {
if (err instanceof ProductNotFoundError) {
res.status(400).json({ error: err.message });
} else {
res.status(500).json({ error: 'Order creation failed' });
}
}
}
}Message Queue Adapter (Driving)
// adapters/input/SQSOrderConsumer.ts — same use case, different adapter
import { CreateOrderUseCase } from '../../ports/input/OrderUseCase';
export class SQSOrderConsumer {
constructor(private createOrderUseCase: CreateOrderUseCase) {}
async processMessage(sqsMessage: SQSMessage): Promise<void> {
const body = JSON.parse(sqsMessage.Body);
await this.createOrderUseCase.createOrder({
customerId: body.customerId,
items: body.items,
});
}
}The same createOrderUseCase handles both HTTP requests and SQS messages — the use case implementation never knows which transport delivered the command.
In-Memory Adapters for Testing
The biggest practical benefit of Hexagonal Architecture: swap the real database adapter for an in-memory adapter in tests.
// test-helpers/InMemoryOrderRepository.ts
import { OrderRepository } from '../../ports/output/OrderRepository';
export class InMemoryOrderRepository implements OrderRepository {
private orders: Map<string, Order> = new Map();
private nextId = 1;
async save(order: Order): Promise<SavedOrder> {
const id = String(this.nextId++);
const saved = { ...order, id };
this.orders.set(id, saved);
return saved;
}
async findById(id: string): Promise<Order | null> {
return this.orders.get(id) ?? null;
}
async findByCustomerId(customerId: string): Promise<Order[]> {
return Array.from(this.orders.values())
.filter(o => o.customerId === customerId);
}
// Test helper — not part of the interface
clear(): void { this.orders.clear(); }
}
// Tests run in milliseconds, no database required
describe('CreateOrderUseCase', () => {
let orderService: OrderApplicationService;
let orderRepo: InMemoryOrderRepository;
let notificationService: MockNotificationService;
beforeEach(() => {
orderRepo = new InMemoryOrderRepository();
notificationService = new MockNotificationService();
orderService = new OrderApplicationService(
orderRepo,
new InMemoryProductCatalog(),
notificationService,
);
});
it('creates an order and sends notification', async () => {
const orderId = await orderService.createOrder({
customerId: 'customer-1',
items: [{ productId: 'product-1', quantity: 2 }],
});
expect(orderId).toBeDefined();
expect(notificationService.sentEmails).toHaveLength(1);
const saved = await orderRepo.findById(orderId);
expect(saved).not.toBeNull();
expect(saved!.items).toHaveLength(1);
});
});Wiring It Together (Dependency Injection)
// infrastructure/container.ts — composition root
import { Pool } from 'pg';
import { createTransport } from 'nodemailer';
// Infrastructure
const db = new Pool({ connectionString: process.env.DATABASE_URL });
const mailer = createTransport({ service: 'sendgrid', auth: { user: 'apikey', pass: process.env.SENDGRID_KEY } });
// Driven adapters
const orderRepository = new PostgreSQLOrderRepository(db);
const productCatalog = new PostgreSQLProductCatalog(db);
const notificationService = new SendGridNotificationService(mailer);
// Application services (use cases)
const createOrderUseCase = new OrderApplicationService(
orderRepository,
productCatalog,
notificationService,
);
// Driving adapters
const httpController = new HTTPOrderController(createOrderUseCase);
const sqsConsumer = new SQSOrderConsumer(createOrderUseCase);The composition root is the only place that knows about all layers. This is where you swap adapters for testing or when you change infrastructure.
Hexagonal vs. Onion vs. Clean Architecture
All three are "clean architectures" sharing the same core principle: dependencies always point inward toward the domain.
| Aspect | Hexagonal | Onion | Clean Architecture |
|---|---|---|---|
| Origin | Alistair Cockburn (2005) | Jeffrey Palermo (2008) | Robert C. Martin (2012) |
| Layers | Two sides (driving/driven) | Concentric rings | Use Cases, Entities, etc. |
| Vocabulary | Ports and Adapters | Domain, Application, Infrastructure | Entities, Use Cases, Interface Adapters |
| Key insight | Symmetric — both sides use ports | Always point inward | Same as Hexagonal, more prescriptive |
| Complexity | Medium | Medium | High |
In practice, the implementations are nearly identical. The vocabulary differs more than the structure.
Frequently Asked Questions
Q: Why is it called "hexagonal"?
The hexagon is a visual convention — it has enough sides to draw multiple ports without implying any specific number. The name does not mean there are exactly six ports. Some systems have two ports (HTTP in, database out). Others have dozens.
Q: Is Hexagonal Architecture overkill for small projects?
Yes, for CRUD applications with simple business logic. The pattern pays off when: you have complex domain logic that benefits from isolation, you need multiple delivery mechanisms (REST + message queue + CLI), or you need comprehensive fast unit tests. For a simple API with no complex rules, a traditional layered architecture with interfaces at the repository boundary achieves 90% of the benefit with less ceremony.
Q: Can I use Hexagonal Architecture in a monolith?
Absolutely. Hexagonal Architecture is about code organization within an application, not about service boundaries. A well-structured monolith using ports and adapters is cleaner and easier to test than a microservices system where each service has a tangled internal structure.
Key Takeaway
Hexagonal Architecture solves the test-infrastructure coupling problem elegantly: define everything your domain needs as interfaces (output ports), define everything the domain exposes as interfaces (input ports), and implement the actual infrastructure in adapters that plug into those ports. Your entire business logic can be tested in milliseconds with in-memory adapters, and you can replace any infrastructure component — the database, email provider, or delivery mechanism — by writing a new adapter without touching the domain. This makes the architecture resilient to technological change and straightforward to test at scale.
Read next: Clean Architecture: The Uncle Bob Way →
Part of the Software Architecture Hub — engineering the core.
