Hexagonal Architecture: Ports and Adapters in Practice
Hexagonal architecture in TypeScript: ports define business contracts, adapters handle infrastructure, and dependency inversion keeps the domain testable.

Hexagonal architecture—also called ports and adapters—solves the problem of business logic that's entangled with frameworks, databases, and HTTP handlers. When your domain logic imports Express, queries the database directly, and formats HTTP responses, you can't test the business rules without standing up the entire infrastructure stack.
The hexagon isolates the domain core behind ports (interfaces) that adapters (implementations) connect to the outside world. The result: business logic that's testable with plain unit tests, infrastructure that's swappable without touching domain code, and a codebase where the architecture reveals intent.
The Core Concept: Ports and Adapters
Ports are interfaces defined by the domain. Driving ports (input) define what the application can do. Driven ports (output) define what the application needs from the outside world.
// ❌ Domain logic coupled to infrastructure
class OrderService {
async createOrder(req: express.Request) {
const items = req.body.items; // Coupled to Express
const user = await db.query( // Coupled to database
"SELECT * FROM users WHERE id = $1",
[req.user.id]
);
// Business logic mixed with infrastructure
const order = { userId: user.id, items, total: 0 };
for (const item of items) {
const product = await db.query(
"SELECT price FROM products WHERE id = $1",
[item.productId]
);
order.total += product.price * item.quantity;
}
await db.query("INSERT INTO orders...", [order]);
return res.json(order); // Coupled to HTTP response
}
}// ✅ Domain core with ports
// ---- DOMAIN TYPES ----
interface OrderItem {
productId: string;
quantity: number;
}
interface Order {
id: string;
userId: string;
items: OrderItem[];
total: number;
status: "pending" | "confirmed" | "shipped";
createdAt: Date;
}
// ---- DRIVING PORT (input) ----
// What the application can do — defined by domain needs
interface OrderUseCase {
createOrder(
userId: string,
items: OrderItem[]
): Promise<Order>;
getOrder(orderId: string): Promise<Order | null>;
confirmOrder(orderId: string): Promise<Order>;
}
// ---- DRIVEN PORTS (output) ----
// What the domain needs from infrastructure
interface OrderRepository {
save(order: Order): Promise<void>;
findById(id: string): Promise<Order | null>;
findByUserId(userId: string): Promise<Order[]>;
}
interface ProductCatalog {
getPrice(productId: string): Promise<number>;
checkAvailability(
productId: string,
quantity: number
): Promise<boolean>;
}
interface PaymentGateway {
charge(
userId: string,
amount: number
): Promise<{ transactionId: string }>;
}
interface EventPublisher {
publish(event: DomainEvent): Promise<void>;
}
type DomainEvent =
| { type: "order.created"; data: Order }
| { type: "order.confirmed"; data: Order };The Application Service (Hexagon Core)
The application service implements the driving port using only driven port interfaces. It contains pure business logic with zero infrastructure dependencies.
class OrderApplicationService implements OrderUseCase {
constructor(
private orderRepo: OrderRepository,
private catalog: ProductCatalog,
private payment: PaymentGateway,
private events: EventPublisher
) {}
async createOrder(
userId: string,
items: OrderItem[]
): Promise<Order> {
// Business rule: validate availability
for (const item of items) {
const available =
await this.catalog.checkAvailability(
item.productId,
item.quantity
);
if (!available) {
throw new DomainError(
`Product ${item.productId} not available ` +
`in quantity ${item.quantity}`
);
}
}
// Business rule: calculate total
let total = 0;
for (const item of items) {
const price = await this.catalog.getPrice(
item.productId
);
total += price * item.quantity;
}
// Business rule: minimum order value
if (total < 10) {
throw new DomainError(
"Minimum order value is $10"
);
}
const order: Order = {
id: generateId(),
userId,
items,
total,
status: "pending",
createdAt: new Date(),
};
await this.orderRepo.save(order);
await this.events.publish({
type: "order.created",
data: order,
});
return order;
}
async confirmOrder(orderId: string): Promise<Order> {
const order = await this.orderRepo.findById(orderId);
if (!order) {
throw new DomainError(`Order ${orderId} not found`);
}
if (order.status !== "pending") {
throw new DomainError(
`Order ${orderId} cannot be confirmed ` +
`from status ${order.status}`
);
}
// Business rule: charge payment
await this.payment.charge(order.userId, order.total);
const confirmed: Order = {
...order,
status: "confirmed",
};
await this.orderRepo.save(confirmed);
await this.events.publish({
type: "order.confirmed",
data: confirmed,
});
return confirmed;
}
async getOrder(orderId: string): Promise<Order | null> {
return this.orderRepo.findById(orderId);
}
}
class DomainError extends Error {
constructor(message: string) {
super(message);
this.name = "DomainError";
}
}Notice what's absent: no SQL, no HTTP, no Express, no message broker SDKs. The application service is pure TypeScript with zero import statements except domain types and port interfaces.
Adapters: Connecting to the Real World
Adapters implement port interfaces, translating between the domain and external systems.
// ---- OUTPUT ADAPTER: PostgreSQL ----
class PostgresOrderRepository implements OrderRepository {
constructor(private pool: Pool) {}
async save(order: Order): Promise<void> {
await this.pool.query(
`INSERT INTO orders (id, user_id, items, total, status, created_at)
VALUES ($1, $2, $3, $4, $5, $6)
ON CONFLICT (id) DO UPDATE SET
status = EXCLUDED.status,
total = EXCLUDED.total`,
[
order.id,
order.userId,
JSON.stringify(order.items),
order.total,
order.status,
order.createdAt,
]
);
}
async findById(id: string): Promise<Order | null> {
const result = await this.pool.query(
"SELECT * FROM orders WHERE id = $1",
[id]
);
if (result.rows.length === 0) return null;
return this.toDomain(result.rows[0]);
}
async findByUserId(userId: string): Promise<Order[]> {
const result = await this.pool.query(
"SELECT * FROM orders WHERE user_id = $1",
[userId]
);
return result.rows.map(this.toDomain);
}
private toDomain(row: any): Order {
return {
id: row.id,
userId: row.user_id,
items: row.items,
total: parseFloat(row.total),
status: row.status,
createdAt: new Date(row.created_at),
};
}
}
// ---- INPUT ADAPTER: Express HTTP ----
class OrderHttpAdapter {
constructor(private orderUseCase: OrderUseCase) {}
createRoutes(): express.Router {
const router = express.Router();
router.post("/orders", async (req, res) => {
try {
const order = await this.orderUseCase.createOrder(
req.user!.id,
req.body.items
);
res.status(201).json(order);
} catch (error) {
if (error instanceof DomainError) {
res.status(400).json({
error: error.message,
});
} else {
res.status(500).json({
error: "Internal server error",
});
}
}
});
router.post(
"/orders/:id/confirm",
async (req, res) => {
try {
const order =
await this.orderUseCase.confirmOrder(
req.params.id
);
res.json(order);
} catch (error) {
if (error instanceof DomainError) {
res.status(400).json({
error: error.message,
});
} else {
res.status(500).json({
error: "Internal server error",
});
}
}
}
);
return router;
}
}Testing Without Infrastructure
The biggest payoff: domain logic is testable with simple mocks, no database, no HTTP server, no Docker containers.
describe("OrderApplicationService", () => {
let service: OrderApplicationService;
let mockRepo: jest.Mocked<OrderRepository>;
let mockCatalog: jest.Mocked<ProductCatalog>;
let mockPayment: jest.Mocked<PaymentGateway>;
let mockEvents: jest.Mocked<EventPublisher>;
beforeEach(() => {
mockRepo = {
save: jest.fn(),
findById: jest.fn(),
findByUserId: jest.fn(),
};
mockCatalog = {
getPrice: jest.fn().mockResolvedValue(25),
checkAvailability: jest.fn().mockResolvedValue(true),
};
mockPayment = {
charge: jest
.fn()
.mockResolvedValue({ transactionId: "tx-123" }),
};
mockEvents = { publish: jest.fn() };
service = new OrderApplicationService(
mockRepo,
mockCatalog,
mockPayment,
mockEvents
);
});
it("creates order with calculated total", async () => {
mockCatalog.getPrice.mockResolvedValue(15);
const order = await service.createOrder("user-1", [
{ productId: "prod-1", quantity: 2 },
]);
expect(order.total).toBe(30);
expect(order.status).toBe("pending");
expect(mockRepo.save).toHaveBeenCalledWith(
expect.objectContaining({ total: 30 })
);
});
it("rejects orders below minimum value", async () => {
mockCatalog.getPrice.mockResolvedValue(3);
await expect(
service.createOrder("user-1", [
{ productId: "prod-1", quantity: 1 },
])
).rejects.toThrow("Minimum order value is $10");
});
it("rejects unavailable products", async () => {
mockCatalog.checkAvailability.mockResolvedValue(false);
await expect(
service.createOrder("user-1", [
{ productId: "prod-1", quantity: 100 },
])
).rejects.toThrow("not available");
});
it("publishes event on order creation", async () => {
await service.createOrder("user-1", [
{ productId: "prod-1", quantity: 1 },
]);
expect(mockEvents.publish).toHaveBeenCalledWith(
expect.objectContaining({ type: "order.created" })
);
});
});These tests run in milliseconds. No database setup, no test containers, no flaky integration issues. The business rules are verified in isolation.
Key Takeaways
Ports are interfaces defined by the domain that create a boundary between business logic and infrastructure—driving ports define what the application does, driven ports define what the application needs. The application service implements driving ports using only driven port interfaces, containing pure business logic with zero framework imports—no SQL, no HTTP, no message broker SDKs. Adapters implement port interfaces to connect the domain to real infrastructure: input adapters translate external requests into domain calls, output adapters translate domain needs into infrastructure operations. Dependency inversion means the domain defines the interfaces and infrastructure implements them, not the other way around—the domain never depends on adapters. Testing the domain core requires only simple mocks of port interfaces, running in milliseconds without databases, HTTP servers, or containers, since business rules are verified in complete isolation from infrastructure.


