
Most applications start with a straightforward layered architecture: controllers call services, services call repositories, repositories talk to the database. This works well initially, but as the application grows, a familiar problem emerges — the business logic becomes entangled with infrastructure details. Changing the database requires modifying service code. Swapping an email provider means touching the order processing logic. Testing business rules requires spinning up a database because the domain code directly depends on database libraries.
Hexagonal architecture, also called ports and adapters, solves this by placing the business logic at the center of the application and pushing all infrastructure concerns to the edges. The domain core defines interfaces (ports) for everything it needs from the outside world, and concrete implementations (adapters) plug into those ports. The result is business logic that depends on nothing external — making it easier to test, easier to modify, and resilient to infrastructure changes.
This foundational guide covers the mental model behind hexagonal architecture, the distinction between ports and adapters, and practical implementation that shows why this pattern pays off as applications grow in complexity.
The Mental Model: Inside vs Outside
Think of your application as having two zones: the inside and the outside.
The inside contains your business logic — the rules, calculations, and decisions that define what your application does. An e-commerce application’s inside includes order validation, pricing calculations, discount rules, and inventory checks. This code has no idea whether it runs behind an HTTP API, a CLI, or a message queue.
The outside contains everything the business logic interacts with — databases, HTTP clients, message queues, email services, file systems, and external APIs. These are infrastructure details that can change without the business rules changing.
The boundary between inside and outside is defined by ports — interfaces that the inside declares and the outside implements. The business logic never imports a database library or an HTTP client. Instead, it declares “I need something that can save an order” or “I need something that can send a notification,” and the infrastructure layer provides concrete implementations.
[HTTP API]
|
[Adapter]
|
[Message Queue] -- [PORT] -- [DOMAIN CORE] -- [PORT] -- [Adapter] -- [Database]
|
[Adapter]
|
[Email Service]
This inversion of dependencies is the core insight. In traditional layered architecture, the domain depends on infrastructure. In hexagonal architecture, infrastructure depends on the domain.
Ports: What the Domain Needs
A port is an interface defined by the domain that describes a capability the domain requires. Ports come in two flavors:
Driving ports (inbound): Define how the outside world interacts with the domain. These are the use cases your application exposes — “create an order,” “process a payment,” “fetch user profile.”
Driven ports (outbound): Define what the domain needs from the outside world. These are the infrastructure capabilities the domain depends on — “save an order,” “send an email,” “check inventory.”
// Driven port: the domain declares what it needs for persistence
interface OrderRepository {
save(order: Order): Promise<void>;
findById(id: string): Promise<Order | null>;
findByCustomerId(customerId: string): Promise<Order[]>;
}
// Driven port: the domain declares what it needs for notifications
interface NotificationService {
sendOrderConfirmation(order: Order, customerEmail: string): Promise<void>;
sendShipmentUpdate(orderId: string, trackingNumber: string): Promise<void>;
}
// Driven port: the domain declares what it needs for payment
interface PaymentGateway {
charge(customerId: string, amount: Money): Promise<PaymentResult>;
refund(paymentId: string): Promise<RefundResult>;
}
These interfaces live inside the domain layer. They contain no references to PostgreSQL, Redis, Stripe, or SendGrid. The domain knows it needs persistence, notifications, and payment processing — but it does not know or care how those capabilities are implemented.
Adapters: How the Outside Connects
An adapter is a concrete implementation of a port. Adapters live outside the domain and handle the infrastructure-specific details.
Driven Adapters (Outbound)
These implement the ports the domain declared. Each adapter translates between the domain’s language and a specific infrastructure technology.
import { Pool } from 'pg';
import { OrderRepository } from '../domain/ports/OrderRepository';
import { Order } from '../domain/models/Order';
class PostgresOrderRepository implements OrderRepository {
constructor(private pool: Pool) {}
async save(order: Order): Promise<void> {
await this.pool.query(
`INSERT INTO orders (id, customer_id, status, total_amount, total_currency, created_at)
VALUES ($1, $2, $3, $4, $5, $6)
ON CONFLICT (id) DO UPDATE SET status = $3, total_amount = $4`,
[order.id, order.customerId, order.status, order.total.amount, order.total.currency, 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 findByCustomerId(customerId: string): Promise<Order[]> { const result = await this.pool.query( ‘SELECT * FROM orders WHERE customer_id = $1 ORDER BY created_at DESC’, [customerId] ); return result.rows.map(row => this.toDomain(row)); } private toDomain(row: any): Order { return new Order(row.id, row.customer_id, row.status, new Money(row.total_amount, row.total_currency), row.created_at); } }
If you later need to switch from PostgreSQL to DynamoDB, you write a new DynamoOrderRepository that implements the same OrderRepository interface. The domain code does not change at all — you only swap the adapter at the composition root.
Driving Adapters (Inbound)
These translate external requests into calls to the domain’s use cases. An HTTP controller is a driving adapter. So is a message queue consumer or a CLI command handler.
import express from 'express';
import { CreateOrderUseCase } from '../domain/usecases/CreateOrderUseCase';
class OrderController {
constructor(private createOrder: CreateOrderUseCase) {}
register(app: express.Application): void {
app.post('/api/orders', async (req, res) => {
try {
const order = await this.createOrder.execute({
customerId: req.body.customerId,
items: req.body.items,
});
res.status(201).json({ orderId: order.id, status: order.status });
} catch (error) {
if (error instanceof InsufficientStockError) {
res.status(409).json({ error: error.message });
} else {
res.status(500).json({ error: 'Internal server error' });
}
}
});
}
}
The controller knows about Express and HTTP status codes, but the CreateOrderUseCase does not. If you add a GraphQL endpoint or a Kafka consumer that also creates orders, they call the same use case through different driving adapters.
The Domain Core: Pure Business Logic
The domain core contains use cases that orchestrate business operations using only ports — no direct infrastructure dependencies.
import { OrderRepository } from '../ports/OrderRepository';
import { PaymentGateway } from '../ports/PaymentGateway';
import { NotificationService } from '../ports/NotificationService';
import { Order, OrderStatus } from '../models/Order';
class CreateOrderUseCase {
constructor(
private orderRepo: OrderRepository,
private paymentGateway: PaymentGateway,
private notificationService: NotificationService,
) {}
async execute(input: CreateOrderInput): Promise<Order> {
const order = Order.create(input.customerId, input.items);
const paymentResult = await this.paymentGateway.charge(
input.customerId, order.total
);
if (!paymentResult.success) {
throw new PaymentFailedError(paymentResult.reason);
}
order.confirm(paymentResult.id);
await this.orderRepo.save(order);
await this.notificationService.sendOrderConfirmation(
order, input.customerEmail
);
return order;
}
}
Every dependency is an interface injected through the constructor. The use case does not import pg, stripe, or nodemailer. Consequently, testing this use case requires only mock implementations of the ports — no database, no payment provider, no email server.
Why Testing Gets Dramatically Easier
The primary practical benefit of hexagonal architecture is testability. Because the domain depends only on interfaces, you can test business logic with fast, isolated unit tests that use in-memory implementations.
class InMemoryOrderRepository implements OrderRepository {
private orders: Map<string, Order> = new Map();
async save(order: Order): Promise<void> {
this.orders.set(order.id, order);
}
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);
}
}
class MockPaymentGateway implements PaymentGateway {
async charge(customerId: string, amount: Money): Promise<PaymentResult> {
return { success: true, id: 'mock-payment-123' };
}
async refund(paymentId: string): Promise<RefundResult> {
return { success: true };
}
}
// Test runs in milliseconds with no external dependencies
describe('CreateOrderUseCase', () => {
it('creates and confirms an order when payment succeeds', async () => {
const orderRepo = new InMemoryOrderRepository();
const paymentGateway = new MockPaymentGateway();
const notifications = new MockNotificationService();
const useCase = new CreateOrderUseCase(orderRepo, paymentGateway, notifications);
const order = await useCase.execute({
customerId: 'cust-1',
customerEmail: 'test@example.com',
items: [{ productId: 'prod-1', quantity: 2, unitPrice: new Money(25, 'USD') }],
});
expect(order.status).toBe(OrderStatus.CONFIRMED);
const saved = await orderRepo.findById(order.id);
expect(saved).not.toBeNull();
});
});
These tests run in milliseconds because they hit no external systems. Furthermore, they test the actual business logic, not the plumbing. When business rules change, you update the domain code and the tests — the adapters remain untouched.
Project Structure
A clean folder structure makes the architecture’s boundaries visible. This layout separates the domain from infrastructure at the file system level.
src/
├── domain/ # The inside — no external dependencies
│ ├── models/
│ │ ├── Order.ts
│ │ ├── Money.ts
│ │ └── OrderItem.ts
│ ├── ports/
│ │ ├── OrderRepository.ts
│ │ ├── PaymentGateway.ts
│ │ └── NotificationService.ts
│ └── usecases/
│ ├── CreateOrderUseCase.ts
│ └── CancelOrderUseCase.ts
├── adapters/ # The outside — infrastructure implementations
│ ├── inbound/
│ │ ├── http/
│ │ │ └── OrderController.ts
│ │ └── messaging/
│ │ └── OrderEventConsumer.ts
│ └── outbound/
│ ├── persistence/
│ │ └── PostgresOrderRepository.ts
│ ├── payment/
│ │ └── StripePaymentGateway.ts
│ └── notification/
│ └── SendGridNotificationService.ts
└── config/
└── container.ts # Dependency injection / composition root
The domain/ folder should have zero imports from adapters/. If your linter or build tool can enforce this import restriction, enable it. For teams structuring Express.js projects for scalability, hexagonal architecture provides a principled alternative to the traditional controller-service-repository layering.
Real-World Scenario: Hexagonal Architecture in a Payment Processing Service
A fintech startup builds a payment processing service that initially supports Stripe for card payments. The domain logic handles payment validation, fraud checks, fee calculations, and transaction recording. The team structures the code with hexagonal architecture from the start, defining a PaymentProcessor port that the Stripe adapter implements.
Eight months later, a major client requires support for bank transfers through a different payment provider. Without hexagonal architecture, this would mean modifying the payment service logic to handle both Stripe and the new provider — mixing infrastructure concerns into business rules. With the ports-and-adapters structure, the team writes a new BankTransferAdapter that implements the same PaymentProcessor port. The domain code — validation, fraud checks, fee calculations — remains completely untouched.
The testing payoff is equally significant. The team runs around 200 domain-level tests in under 3 seconds using in-memory adapters. Integration tests against real Stripe and bank transfer APIs run separately and take several minutes, but they only test the adapter implementations, not the business logic. This separation means developers get fast feedback on business rule changes while still verifying that adapters work correctly against real services.
When to Use Hexagonal Architecture
- Business logic is complex enough that isolating it from infrastructure details improves clarity and testability
- You anticipate swapping infrastructure components (databases, payment providers, notification services) during the application’s lifetime
- Multiple entry points (HTTP, CLI, message queues) need to trigger the same business logic
- The team values fast unit tests that do not require external services to run
When NOT to Use Hexagonal Architecture
- The application is a thin wrapper around a database (CRUD with minimal logic) — the abstraction adds overhead without meaningful benefit
- You are building a prototype or MVP where speed of delivery matters more than long-term architecture
- The team is unfamiliar with dependency injection and interface-based design — introducing hexagonal architecture alongside learning a new framework creates too much cognitive load at once
- Infrastructure is unlikely to change and the application has a single entry point — the indirection adds complexity without a concrete payoff
Common Mistakes with Hexagonal Architecture
- Creating ports that mirror the database schema instead of expressing domain capabilities — ports should describe what the domain needs (“save an order”), not how the database works (“insert a row into the orders table”)
- Leaking infrastructure types into the domain layer (importing ORM entities, HTTP request objects, or message queue types in domain code)
- Over-abstracting by creating ports for everything, including utilities like date formatting or string manipulation that have no reason to be swappable
- Not enforcing the dependency rule — without a linter or build-time check, developers gradually add infrastructure imports to the domain folder, eroding the boundary
- Confusing hexagonal architecture with clean architecture — they share the dependency inversion principle, but clean architecture prescribes more layers (entities, use cases, interface adapters, frameworks), while hexagonal architecture focuses specifically on the ports-and-adapters boundary
Building on Hexagonal Architecture
Hexagonal architecture gives you one powerful idea: the domain depends on nothing external. Ports declare what the domain needs, adapters provide concrete implementations, and the composition root wires them together. This inversion keeps business logic testable, infrastructure swappable, and the codebase navigable as it grows.
Start by identifying the boundaries in your current application. Which parts are business logic and which parts are infrastructure plumbing? Define ports for the infrastructure dependencies your domain currently imports directly, then extract the implementations into adapters. Even applying this pattern to a single module demonstrates the testability and clarity gains that hexagonal architecture provides at scale.