Hexagonal Architecture in Modern Applications
A practical guide to building maintainable systems in an age of distributed complexity

Every few years, the industry reinvents separation of concerns. Microservices promised isolation but delivered distributed monoliths. Event-driven systems promised loose coupling but introduced operational complexity that few teams were ready for. Hexagonal Architecture, born from Alistair Cockburn’s early 2000s work, keeps resurfacing because it addresses a timeless pain point: how to build software that can change without breaking when requirements, frameworks, or infrastructure evolve. Right now, as teams adopt cloud-native patterns, serverless, and event streaming, the boundaries between business logic and technical plumbing are under constant stress. Hexagonal thinking offers a stable core and a set of interchangeable ports and adapters that make those shifts manageable.
You might have heard it called “ports and adapters.” You may have seen diagrams with a hexagon and wondered whether it’s overkill for a small service or a silver bullet for a sprawling monolith. The truth lies in the middle: it’s not about the shape; it’s about the discipline. In this post, I’ll walk through how hexagonal architecture shows up in real-world applications, where it helps, where it hurts, and how to get started without boiling the ocean.
Why this approach fits today’s landscape
Modern applications rarely live in one place. They integrate with third-party APIs, talk to multiple databases, push events to streams, and serve user interfaces and machine-to-machine endpoints simultaneously. Frameworks evolve quickly; teams switch between Node.js, Kotlin, Python, or Go as business needs change. The risk isn’t writing the first version; it’s maintaining the fifth.
Hexagonal architecture is popular in teams that expect change. You’ll find it in backend services at mid-sized SaaS companies, in payment platforms that must integrate with multiple gateways, and in domain-driven design (DDD) codebases where business rules are complex. Compared to layered architecture, it emphasizes boundaries: your domain logic doesn’t know about HTTP or SQL. Compared to microservices, it’s complementary; you can apply hexagonal design inside a single service to keep its internal complexity manageable, which helps avoid the “distributed monolith” trap.
When we talk about alternatives, layered architecture often starts simple but quickly lets infrastructure details seep into the domain. Event-driven systems push complexity into orchestration and observability. Hexagonal design places a clear boundary around the domain, making it easier to replace delivery mechanisms or infrastructure without touching business rules.
Core ideas: ports, adapters, and the domain core
At the heart of hexagonal architecture are three parts:
- The domain core: pure business logic and rules. It doesn’t depend on frameworks, databases, or HTTP.
- Ports: interfaces that define how the outside world interacts with the domain. There are input ports (use cases driven by external triggers) and output ports (domain needs like persistence or notifications).
- Adapters: concrete implementations of ports. They translate between the outside world and the domain.
The key principle is dependency inversion: the domain defines interfaces; infrastructure implements them. This allows you to plug in different adapters without changing the core.
A small but real example: a shipping cost calculator
Imagine a service that calculates shipping costs based on package weight, destination, and negotiated carrier rates. The domain knows rules like “zones have base rates” and “volume weight can override actual weight.” The infrastructure chooses between fetching rates from a carrier API, a database cache, or a mock for tests.
The domain defines two ports:
- An input port for the “CalculateShipping” use case.
- An output port for “RateProvider,” so the domain can request carrier rates.
Adapters implement these ports:
- An HTTP adapter receives requests and calls the input port.
- A database adapter stores historical calculations.
- A carrier adapter calls an external API; an in-memory adapter returns stubs for tests.
Code: the domain core
Here’s a simplified TypeScript example that focuses on the domain. Notice how none of this code imports HTTP or database libraries.
// domain/rate.ts
export interface RateQuery {
weightGrams: number;
destinationZone: string;
}
export interface ShippingRate {
carrier: string;
service: string;
costCents: number;
currency: string;
}
// domain/rate-provider-port.ts
export interface RateProvider {
getRates(query: RateQuery): Promise<ShippingRate[]>;
}
// domain/calculate-shipping.ts
export interface CalculateShippingInput {
weightGrams: number;
destinationZone: string;
}
export class CalculateShipping {
constructor(private rateProvider: RateProvider) {}
async execute(input: CalculateShippingInput): Promise<ShippingRate[]> {
const rates = await this.rateProvider.getRates(input);
// Domain rule: filter out unavailable services
return rates.filter(r => r.costCents > 0);
}
}
The domain class CalculateShipping depends only on the RateProvider port interface. It doesn’t care where rates come from.
Code: adapter implementations
Now let’s implement two adapters: one that calls an external API, and one that returns stubs for testing.
// adapters/http-server.ts
import express from 'express';
import { CalculateShipping, CalculateShippingInput } from '../domain/calculate-shipping';
export function startServer(useCase: CalculateShipping) {
const app = express();
app.use(express.json());
app.post('/shipping/calculate', async (req, res) => {
const input: CalculateShippingInput = {
weightGrams: req.body.weightGrams,
destinationZone: req.body.destinationZone,
};
try {
const rates = await useCase.execute(input);
res.json({ rates });
} catch (err) {
res.status(500).json({ error: 'Failed to calculate shipping' });
}
});
return app.listen(3000, () => {
console.log('HTTP adapter listening on port 3000');
});
}
// adapters/in-memory-rate-provider.ts
import { RateProvider, RateQuery, ShippingRate } from '../domain/rate';
export class InMemoryRateProvider implements RateProvider {
private rates: ShippingRate[];
constructor(rates: ShippingRate[]) {
this.rates = rates;
}
async getRates(query: RateQuery): Promise<ShippingRate[]> {
// In real tests, you might filter by zone or weight thresholds
return this.rates;
}
}
// adapters/carrier-api-rate-provider.ts
import { RateProvider, RateQuery, ShippingRate } from '../domain/rate';
export class CarrierApiRateProvider implements RateProvider {
constructor(private apiUrl: string, private apiKey: string) {}
async getRates(query: RateQuery): Promise<ShippingRate[]> {
const url = `${this.apiUrl}/rates?zone=${query.destinationZone}&weight=${query.weightGrams}`;
const resp = await fetch(url, {
headers: { Authorization: `Bearer ${this.apiKey}` },
});
if (!resp.ok) throw new Error('Carrier API failed');
const json = await resp.json();
// Map external shape to our domain shape
return json.rates.map((r: any) => ({
carrier: r.carrier,
service: r.service,
costCents: Math.round(r.cost * 100),
currency: 'USD',
}));
}
}
Code: wiring everything together
Composition happens outside the domain, often in an infrastructure layer. Here’s a minimal wire-up using environment variables to choose adapters.
// main.ts
import { CalculateShipping } from './domain/calculate-shipping';
import { CarrierApiRateProvider } from './adapters/carrier-api-rate-provider';
import { InMemoryRateProvider } from './adapters/in-memory-rate-provider';
import { startServer } from './adapters/http-server';
function createRateProvider() {
const providerType = process.env.RATE_PROVIDER_TYPE ?? 'carrier';
if (providerType === 'in-memory') {
return new InMemoryRateProvider([
{ carrier: 'ACME', service: 'Ground', costCents: 1200, currency: 'USD' },
{ carrier: 'ACME', service: 'Air', costCents: 2800, currency: 'USD' },
]);
}
const apiUrl = process.env.CARRIER_API_URL!;
const apiKey = process.env.CARRIER_API_KEY!;
return new CarrierApiRateProvider(apiUrl, apiKey);
}
const useCase = new CalculateShipping(createRateProvider());
startServer(useCase);
This wiring allows you to run locally with in-memory stubs and deploy to production with the carrier API adapter. The domain never changes between environments.
Real-world case: event-driven payment processing
Hexagonal architecture shines when you need to support multiple triggers and outputs. Consider a payment processing service. It might accept commands via HTTP from a checkout UI and events from a message queue when external providers send callbacks. The domain logic—validating transactions, enforcing limits, and emitting outcomes—should be agnostic to delivery.
Domain interfaces
// domain/payment.ts
export interface PaymentCommand {
amountCents: number;
currency: string;
customerId: string;
}
export interface PaymentResult {
transactionId: string;
status: 'authorized' | 'declined' | 'error';
message?: string;
}
// domain/payment-repository-port.ts
export interface PaymentRepository {
save(result: PaymentResult): Promise<void>;
findById(id: string): Promise<PaymentResult | null>;
}
// domain/payment-gateway-port.ts
export interface PaymentGateway {
authorize(cmd: PaymentCommand): Promise<PaymentResult>;
}
// domain/process-payment.ts
export class ProcessPayment {
constructor(
private gateway: PaymentGateway,
private repository: PaymentRepository,
) {}
async execute(cmd: PaymentCommand): Promise<PaymentResult> {
const result = await this.gateway.authorize(cmd);
await this.repository.save(result);
return result;
}
}
Adapters for HTTP and events
// adapters/http-payments.ts
import express from 'express';
import { ProcessPayment, PaymentCommand } from '../domain/process-payment';
export function startPaymentHttp(useCase: ProcessPayment) {
const app = express();
app.use(express.json());
app.post('/payments/authorize', async (req, res) => {
const cmd: PaymentCommand = {
amountCents: req.body.amountCents,
currency: req.body.currency,
customerId: req.body.customerId,
};
const result = await useCase.execute(cmd);
res.status(result.status === 'declined' ? 402 : 200).json(result);
});
return app.listen(3001, () => console.log('Payment HTTP adapter on 3001'));
}
// adapters/event-adapter.ts
import { ProcessPayment } from '../domain/process-payment';
export interface PaymentAuthorizedEvent {
type: 'PaymentAuthorized';
transactionId: string;
amountCents: number;
}
export function bindEventAdapter(queueClient: any, useCase: ProcessPayment) {
queueClient.subscribe('payment.requests', async (msg: any) => {
const cmd: PaymentCommand = {
amountCents: msg.amountCents,
currency: msg.currency,
customerId: msg.customerId,
};
const result = await useCase.execute(cmd);
const event: PaymentAuthorizedEvent = {
type: 'PaymentAuthorized',
transactionId: result.transactionId,
amountCents: msg.amountCents,
};
await queueClient.publish('payment.events', event);
});
}
In production, you might use AWS SQS, Kafka, or RabbitMQ. The domain doesn’t know about any of these. Adapters translate messages into PaymentCommand and publish PaymentResult as events.
Fun language fact
TypeScript’s structural typing makes it easy to pass lightweight objects across adapter boundaries without ceremony. In languages like Java or C#, you’d typically define interfaces and DTO classes explicitly. In TypeScript, a “duck-typed” shape is often sufficient, which speeds up prototyping. Be careful though; when you grow a codebase, explicit interfaces and validation remain essential to avoid drift between adapters and the domain.
Strengths, weaknesses, and tradeoffs
When done well, hexagonal architecture offers major benefits:
- Strong boundary between domain and infrastructure, making changes safer.
- Easy testing: swap adapters for in-memory or fake implementations.
- Flexibility: swap HTTP for gRPC or CLI without touching business logic.
- Easier refactors: isolate legacy systems behind adapters.
But it’s not free:
- Overhead for very small services. A simple CRUD app might not need ports and adapters.
- Learning curve for teams used to framework-driven development.
- Risk of “adapter spaghetti” if you don’t standardize error handling and data shapes.
- Debugging across adapter boundaries can add mental load, especially in event-driven flows.
Consider a small internal tool that integrates with one database and one admin UI; a layered architecture may be simpler. For a payment service with multiple gateways and compliance requirements, hexagonal design pays off.
Personal experience: learning, mistakes, and wins
I started using hexagonal patterns after a painful migration where we switched from a monolithic REST service to a system that needed to support both REST and gRPC, plus a background worker. Our business logic was scattered across controller-like classes that depended on Spring annotations and JPA entities. The change required touching dozens of files and introduced subtle bugs.
We refactored by extracting a domain layer with input and output ports. The first adapter we rebuilt was the REST controller; the second was a gRPC service that called the same input ports. The third was a worker that consumed events and invoked the same use cases. This immediately revealed duplicated validation logic that had crept into different controllers. Unifying it inside the domain reduced bugs during a later PCI audit.
Common mistakes I’ve seen:
- Leaking infrastructure into the domain: using HTTP status codes or SQL exceptions inside business logic.
- Over-engineering: creating ports for trivial operations that never change.
- Inconsistent error handling: adapters translate errors inconsistently, confusing clients.
- Skipping contract tests: adapters drift over time; a small test suite verifying contracts prevents surprises.
A moment that proved its value: during a carrier outage, we switched from the Carrier API adapter to a cached rates adapter in under an hour. The domain didn’t change; we only altered composition and configuration. That kind of change is what makes teams confident in production.
Getting started: setup, tooling, and workflow
You don’t need a fancy framework to apply hexagonal thinking. The most important part is a clear package structure that enforces dependency direction.
Project structure
A simple Node/TypeScript layout:
src/
domain/
rate.ts
rate-provider-port.ts
calculate-shipping.ts
adapters/
http-server.ts
in-memory-rate-provider.ts
carrier-api-rate-provider.ts
database-adapter.ts
infrastructure/
config.ts
compose.ts
tests/
integration/
http.test.ts
contract/
carrier-api-contract.test.ts
main.ts
Domain code should never import adapters. Adapters import domain ports. Infrastructure wires them together.
Configuration and environment
Prefer dependency injection style composition over global state. This makes testing easier and clarifies adapter boundaries.
// infrastructure/compose.ts
import { CalculateShipping } from '../domain/calculate-shipping';
import { CarrierApiRateProvider } from '../adapters/carrier-api-rate-provider';
import { InMemoryRateProvider } from '../adapters/in-memory-rate-provider';
import { DatabaseAdapter } from '../adapters/database-adapter';
import { startServer } from '../adapters/http-server';
export async function bootstrap() {
const providerType = process.env.RATE_PROVIDER_TYPE ?? 'carrier';
const rateProvider =
providerType === 'in-memory'
? new InMemoryRateProvider([
{ carrier: 'ACME', service: 'Ground', costCents: 1200, currency: 'USD' },
])
: new CarrierApiRateProvider(process.env.CARRIER_API_URL!, process.env.CARRIER_API_KEY!);
const db = new DatabaseAdapter(process.env.DATABASE_URL!);
const useCase = new CalculateShipping(rateProvider);
const server = startServer(useCase);
// Graceful shutdown
process.on('SIGTERM', async () => {
server.close();
await db.close();
});
}
Testing approach
- Unit tests: mock ports directly; test domain rules.
- Integration tests: start the HTTP adapter and call endpoints with in-memory adapters.
- Contract tests: verify adapter outputs against mock external systems.
For example, a simple contract test against a mocked carrier API ensures your adapter mapping remains correct, even when the external team changes fields.
Distinctive features and developer experience
Hexagonal architecture’s biggest value is developer confidence. When a new delivery mechanism is requested, you add a new adapter rather than rip through the domain. This speeds up feature delivery and reduces regression risk.
Ecosystem strengths vary by language but the pattern is universal. In TypeScript, structural typing and lightweight modules make it easy to keep adapters thin. In Java, Spring or Quarkus can help wire components, but be careful not to leak annotations into the domain. In Go, explicit interfaces and small packages keep boundaries clean. In Python, dataclasses and protocols can model ports nicely.
Maintainability improves when you combine hexagonal design with contract testing and clear error strategies. Define how errors propagate: domain throws domain-specific errors; adapters translate them into HTTP responses or message attributes. Document these translations; they become part of your public API.
Free learning resources
- Alistair Cockburn’s Port and Adapter Original Paper: https://alistair.cockburn.com/hexagonal-architecture. Start here to understand the original intent.
- Martin Fowler’s description: https://martinfowler.com/bliki/HexagonalArchitecture.html. A concise overview with diagrams.
- “Domain-Driven Design” by Eric Evans: https://domainlanguage.com/ddd/. DDD and hexagonal design complement each other; Evans’ book provides the broader context.
- Thoughtworks Technology Radar on hexagonal architecture: https://www.thoughtworks.com/insights/blog. Look for articles discussing ports and adapters in practice.
- freeCodeCamp’s tutorial on clean architecture in TypeScript: https://www.freecodecamp.org/news/clean-architecture-for-nodejs-applications/. Good for practical patterns.
- Libraries that support hexagonal design: NestJS (https://nestjs.com/) for Node; Spring Boot (https://spring.io/projects/spring-boot) with dependency injection for Java; go-kit (https://gokit.io/) for Go.
Summary and guidance
Hexagonal architecture is a good fit if:
- Your application integrates with multiple external systems or delivery mechanisms.
- Your domain logic is valuable and should not be entangled with frameworks.
- You need to test business rules without spinning up infrastructure.
- You anticipate changes in transport or data stores (HTTP to gRPC, SQL to NoSQL, on-prem to cloud).
You might skip it or apply it lightly if:
- You’re building a simple internal tool with one interface and one database.
- Your team is under extreme time pressure and the domain is trivial.
- You’re already comfortable with layered architecture and change is rare.
The core takeaway: hexagonal design is about boundaries, not bureaucracy. Start by extracting ports for the core operations you expect to outlive any particular technology. Build adapters to translate from the world into your domain. Keep the hexagon clean, and you’ll find that changes that once felt risky become routine. In a world where technology choices shift as quickly as business priorities, that’s a practical superpower.




