What is Hexagonal Architecture?
Hexagonal Architecture, also known as Ports and Adapters, was introduced by Alistair Cockburn. The central idea is to isolate the application core from external concerns by defining explicit boundaries through ports (interfaces) and adapters (implementations). The application is at the center, and all external systems interact with it through well-defined ports.
Core Concepts
- Application Core: Contains business logic and domain models — has no knowledge of the outside world
- Ports: Interfaces that define how the application communicates with the outside — can be driving (primary) or driven (secondary)
- Adapters: Concrete implementations of ports that translate between the application and external systems
- Driving Side (Left): Actors that initiate interaction with the application (HTTP controllers, CLI, tests)
- Driven Side (Right): Infrastructure the application uses (databases, message queues, external APIs)
Ports: The Application Boundary
Ports are interfaces defined by the application core. Driving ports (or primary ports) define what the application offers to the outside world — they are the use cases. Driven ports (or secondary ports) define what the application needs from the outside world — they are the repository interfaces, messaging ports, and external service contracts.
// ports/driving/OrderService.ts (Primary Port - what the app offers)
export interface OrderService {
placeOrder(command: PlaceOrderCommand): Promise;
cancelOrder(orderId: string): Promise;
getOrderStatus(orderId: string): Promise;
}
export interface PlaceOrderCommand {
customerId: string;
items: Array<{ productId: string; quantity: number }>;
shippingAddress: Address;
}
export interface OrderConfirmation {
orderId: string;
estimatedDelivery: Date;
totalAmount: number;
}
// ports/driven/OrderRepository.ts (Secondary Port - what the app needs)
export interface OrderRepository {
save(order: Order): Promise;
findById(id: string): Promise;
findByCustomerId(customerId: string): Promise;
}
// ports/driven/PaymentGateway.ts (Secondary Port)
export interface PaymentGateway {
charge(amount: number, currency: string, customerId: string): Promise;
refund(paymentId: string): Promise;
}
// ports/driven/NotificationSender.ts (Secondary Port)
export interface NotificationSender {
sendOrderConfirmation(email: string, order: OrderConfirmation): Promise;
sendShippingUpdate(email: string, trackingId: string): Promise;
}
Application Core
The application core implements the driving ports and depends only on the driven port interfaces. It contains all business logic and orchestrates the flow between domain entities and the driven ports.
// core/domain/Order.ts
export class Order {
private _status: OrderStatus = "pending";
private _items: OrderItem[] = [];
private _totalAmount: number = 0;
constructor(
public readonly id: string,
public readonly customerId: string,
public readonly shippingAddress: Address
) {}
addItem(productId: string, quantity: number, unitPrice: number): void {
if (quantity <= 0) throw new Error("Quantity must be positive");
this._items.push({ productId, quantity, unitPrice });
this._totalAmount += quantity * unitPrice;
}
confirm(): void {
if (this._items.length === 0) throw new Error("Cannot confirm empty order");
this._status = "confirmed";
}
cancel(): void {
if (this._status === "shipped") throw new Error("Cannot cancel shipped order");
this._status = "cancelled";
}
get status(): OrderStatus { return this._status; }
get items(): ReadonlyArray { return this._items; }
get totalAmount(): number { return this._totalAmount; }
}
// core/services/OrderServiceImpl.ts
import { OrderService, PlaceOrderCommand, OrderConfirmation } from "../../ports/driving/OrderService";
import { OrderRepository } from "../../ports/driven/OrderRepository";
import { PaymentGateway } from "../../ports/driven/PaymentGateway";
import { NotificationSender } from "../../ports/driven/NotificationSender";
import { Order } from "../domain/Order";
export class OrderServiceImpl implements OrderService {
constructor(
private readonly orderRepo: OrderRepository,
private readonly paymentGateway: PaymentGateway,
private readonly notificationSender: NotificationSender,
private readonly idGenerator: () => string
) {}
async placeOrder(command: PlaceOrderCommand): Promise {
const order = new Order(
this.idGenerator(),
command.customerId,
command.shippingAddress
);
for (const item of command.items) {
// In real code, you would look up prices from a catalog service
order.addItem(item.productId, item.quantity, 0);
}
// Charge the customer
const payment = await this.paymentGateway.charge(
order.totalAmount, "USD", command.customerId
);
if (!payment.success) {
throw new Error("Payment failed: " + payment.errorMessage);
}
order.confirm();
await this.orderRepo.save(order);
const confirmation: OrderConfirmation = {
orderId: order.id,
estimatedDelivery: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000),
totalAmount: order.totalAmount,
};
// Notification is fire-and-forget
this.notificationSender.sendOrderConfirmation(
command.customerId, confirmation
).catch(err => console.error("Failed to send notification:", err));
return confirmation;
}
async cancelOrder(orderId: string): Promise {
const order = await this.orderRepo.findById(orderId);
if (!order) throw new Error("Order not found");
order.cancel();
await this.orderRepo.save(order);
}
async getOrderStatus(orderId: string): Promise {
const order = await this.orderRepo.findById(orderId);
if (!order) throw new Error("Order not found");
return order.status;
}
}
Adapters: Connecting to the Real World
Adapters sit on the outside of the hexagon. Driving adapters call into the application core through the primary ports. Driven adapters implement the secondary port interfaces and connect to actual infrastructure.
// adapters/driving/ExpressOrderController.ts (Driving Adapter)
import { Router, Request, Response } from "express";
import { OrderService } from "../../ports/driving/OrderService";
export function createOrderRouter(orderService: OrderService): Router {
const router = Router();
router.post("/orders", async (req: Request, res: Response) => {
try {
const confirmation = await orderService.placeOrder(req.body);
res.status(201).json(confirmation);
} catch (error) {
res.status(400).json({ error: (error as Error).message });
}
});
router.delete("/orders/:id", async (req: Request, res: Response) => {
try {
await orderService.cancelOrder(req.params.id);
res.status(204).send();
} catch (error) {
res.status(400).json({ error: (error as Error).message });
}
});
return router;
}
// adapters/driven/MongoOrderRepository.ts (Driven Adapter)
import { OrderRepository } from "../../ports/driven/OrderRepository";
import { Order } from "../../core/domain/Order";
import { Collection, Db } from "mongodb";
export class MongoOrderRepository implements OrderRepository {
private collection: Collection;
constructor(db: Db) {
this.collection = db.collection("orders");
}
async save(order: Order): Promise {
await this.collection.updateOne(
{ _id: order.id },
{ $set: this.toDocument(order) },
{ upsert: true }
);
}
async findById(id: string): Promise {
const doc = await this.collection.findOne({ _id: id });
return doc ? this.toDomain(doc) : null;
}
async findByCustomerId(customerId: string): Promise {
const docs = await this.collection.find({ customerId }).toArray();
return docs.map(doc => this.toDomain(doc));
}
private toDocument(order: Order): Record {
return {
_id: order.id,
customerId: order.customerId,
status: order.status,
items: order.items,
totalAmount: order.totalAmount,
shippingAddress: order.shippingAddress,
};
}
private toDomain(doc: Record): Order {
// Reconstruct domain entity from persistence format
const order = new Order(
doc._id as string,
doc.customerId as string,
doc.shippingAddress as Address
);
// Restore state...
return order;
}
}
// adapters/driven/StripePaymentGateway.ts (Driven Adapter)
import { PaymentGateway, PaymentResult } from "../../ports/driven/PaymentGateway";
export class StripePaymentGateway implements PaymentGateway {
constructor(private readonly stripeApiKey: string) {}
async charge(amount: number, currency: string, customerId: string): Promise {
// Stripe-specific implementation
const response = await fetch("https://api.stripe.com/v1/charges", {
method: "POST",
headers: { Authorization: `Bearer ${this.stripeApiKey}` },
body: new URLSearchParams({ amount: String(amount), currency, customer: customerId }),
});
const data = await response.json();
return { success: data.status === "succeeded", paymentId: data.id };
}
async refund(paymentId: string): Promise {
// Stripe refund implementation
return { success: true, refundId: "ref_" + paymentId };
}
}
Composition Root
The composition root is where all dependencies are wired together. This is typically in the application entry point, outside the hexagon. Dependency injection frameworks can help but are not required.
// main.ts - Composition Root
import express from "express";
import { MongoClient } from "mongodb";
import { OrderServiceImpl } from "./core/services/OrderServiceImpl";
import { MongoOrderRepository } from "./adapters/driven/MongoOrderRepository";
import { StripePaymentGateway } from "./adapters/driven/StripePaymentGateway";
import { EmailNotificationSender } from "./adapters/driven/EmailNotificationSender";
import { createOrderRouter } from "./adapters/driving/ExpressOrderController";
import { v4 as uuidv4 } from "uuid";
async function bootstrap() {
const mongo = await MongoClient.connect(process.env.MONGO_URL!);
const db = mongo.db("orders");
// Create driven adapters
const orderRepo = new MongoOrderRepository(db);
const paymentGateway = new StripePaymentGateway(process.env.STRIPE_KEY!);
const notificationSender = new EmailNotificationSender(process.env.SMTP_URL!);
// Create application core
const orderService = new OrderServiceImpl(
orderRepo, paymentGateway, notificationSender, uuidv4
);
// Create driving adapters
const app = express();
app.use(express.json());
app.use("/api", createOrderRouter(orderService));
app.listen(3000, () => console.log("Server started"));
}
bootstrap();
Hexagonal vs Clean Architecture
- Similarity: Both aim to isolate business logic from infrastructure — they share the dependency inversion principle
- Hexagonal focus: Emphasizes the symmetry between driving and driven sides — all external actors are treated equally
- Clean focus: Defines more explicit concentric layers with a stricter dependency rule between them
- In practice: Both architectures lead to very similar code structures in TypeScript projects
Testing with Hexagonal Architecture
The beauty of hexagonal architecture is that you can test the entire application core without any real infrastructure. Simply create in-memory implementations of the driven ports.
// tests/core/OrderService.test.ts
import { OrderServiceImpl } from "../../core/services/OrderServiceImpl";
class InMemoryOrderRepo implements OrderRepository {
private orders = new Map();
async save(order: Order) { this.orders.set(order.id, order); }
async findById(id: string) { return this.orders.get(id) || null; }
async findByCustomerId(cid: string) {
return [...this.orders.values()].filter(o => o.customerId === cid);
}
}
class FakePaymentGateway implements PaymentGateway {
shouldFail = false;
async charge() {
return this.shouldFail
? { success: false, errorMessage: "Declined" }
: { success: true, paymentId: "pay_123" };
}
async refund() { return { success: true, refundId: "ref_123" }; }
}
class FakeNotificationSender implements NotificationSender {
sent: string[] = [];
async sendOrderConfirmation(email: string) { this.sent.push(email); }
async sendShippingUpdate(email: string) { this.sent.push(email); }
}
describe("OrderService", () => {
it("places an order successfully", async () => {
const repo = new InMemoryOrderRepo();
const payments = new FakePaymentGateway();
const notifications = new FakeNotificationSender();
let counter = 0;
const service = new OrderServiceImpl(
repo, payments, notifications, () => String(++counter)
);
const result = await service.placeOrder({
customerId: "cust_1",
items: [{ productId: "prod_1", quantity: 2 }],
shippingAddress: { street: "123 Main", city: "NYC", zip: "10001" },
});
expect(result.orderId).toBe("1");
});
it("fails when payment is declined", async () => {
const payments = new FakePaymentGateway();
payments.shouldFail = true;
const service = new OrderServiceImpl(
new InMemoryOrderRepo(), payments, new FakeNotificationSender(), () => "1"
);
await expect(service.placeOrder({
customerId: "cust_1",
items: [{ productId: "prod_1", quantity: 1 }],
shippingAddress: { street: "123 Main", city: "NYC", zip: "10001" },
})).rejects.toThrow("Payment failed");
});
});
Best Practices
- Keep ports minimal: Each port should represent a single responsibility — avoid god interfaces
- Name ports from the application's perspective: Use OrderRepository, not MongoOrderStore
- Don't leak infrastructure types: Ports should use domain types, not library-specific types
- Composition root is the only place with all dependencies: Keep it in the outermost layer