Web Development · Architecture Patterns
Hexagonal Architecture: When Ports and Adapters Are Worth the Extra Layer
Hexagonal architecture keeps your business logic from importing your database driver, your HTTP framework, or your payment SDK. It also adds files and indirection you don't always need. Here's how it works, a working example, and how to tell if your project actually needs it.
Prathviraj Singh
6 min read
Sponsored
A payment processor migration should be a config change and a new adapter file. In most codebases, it’s a multi-week project touching a dozen files, because payment SDK types leaked into business logic years ago and nobody drew a line to stop it. Hexagonal architecture is that line, drawn deliberately, before you need it.
The core idea
Hexagonal architecture, also known as ports and adapters, rests on one rule: your domain logic, the code that actually encodes business rules, never imports infrastructure directly. Instead, the domain defines interfaces describing what it needs, called ports, and infrastructure code implements those interfaces in adapters that plug into the domain from the outside.

The name comes from drawing the domain as a hexagon, an arbitrary shape chosen specifically to avoid implying a “top” or “bottom,” because the point is that no side is privileged. A database adapter and an HTTP adapter are peers from the domain’s point of view, both just implementations of a port it defined.
A concrete example
Say you’re building order processing. The domain needs to persist orders and charge a card. It doesn’t need to know whether persistence is Postgres or DynamoDB, or whether charging a card means Stripe or Braintree.
Define the ports as interfaces the domain owns:
// domain/ports.ts
export interface OrderRepository {
save(order: Order): Promise<void>;
findById(id: string): Promise<Order | null>;
}
export interface PaymentGateway {
charge(amount: number, cardToken: string): Promise<{ success: boolean; transactionId: string }>;
}
The domain logic depends only on these interfaces, never on a concrete database client or payment SDK:
// domain/order-service.ts
export class OrderService {
constructor(
private orders: OrderRepository,
private payments: PaymentGateway
) {}
async placeOrder(order: Order, cardToken: string): Promise<void> {
if (order.total <= 0) {
throw new Error("Order total must be positive");
}
const result = await this.payments.charge(order.total, cardToken);
if (!result.success) {
throw new Error("Payment failed");
}
order.status = "paid";
order.transactionId = result.transactionId;
await this.orders.save(order);
}
}
Notice what’s absent: no import { Pool } from 'pg', no import Stripe from 'stripe'. OrderService has no idea what database or payment provider is behind the interfaces it was handed.
Adapters implement those ports using real infrastructure:
// adapters/postgres-order-repository.ts
import { Pool } from "pg";
import type { OrderRepository } from "../domain/ports";
export class PostgresOrderRepository implements OrderRepository {
constructor(private pool: Pool) {}
async save(order: Order): Promise<void> {
await this.pool.query(
"INSERT INTO orders (id, total, status, transaction_id) VALUES ($1, $2, $3, $4) ON CONFLICT (id) DO UPDATE SET status = $3, transaction_id = $4",
[order.id, order.total, order.status, order.transactionId]
);
}
async findById(id: string): Promise<Order | null> {
const result = await this.pool.query("SELECT * FROM orders WHERE id = $1", [id]);
return result.rows[0] ? mapRowToOrder(result.rows[0]) : null;
}
}
// adapters/stripe-payment-gateway.ts
import Stripe from "stripe";
import type { PaymentGateway } from "../domain/ports";
export class StripePaymentGateway implements PaymentGateway {
constructor(private stripe: Stripe) {}
async charge(amount: number, cardToken: string) {
const charge = await this.stripe.charges.create({
amount: Math.round(amount * 100),
currency: "usd",
source: cardToken,
});
return { success: charge.status === "succeeded", transactionId: charge.id };
}
}
Wiring happens at the application’s entry point, the only place that knows about both the domain and the concrete adapters:
// main.ts
const orderService = new OrderService(
new PostgresOrderRepository(pgPool),
new StripePaymentGateway(stripeClient)
);
Where the payoff actually shows up
The immediate benefit is testing. OrderService’s unit tests never touch a real database or Stripe’s API:
// domain/order-service.test.ts
class FakeOrderRepository implements OrderRepository {
private store = new Map<string, Order>();
async save(order: Order) { this.store.set(order.id, order); }
async findById(id: string) { return this.store.get(id) ?? null; }
}
class FakePaymentGateway implements PaymentGateway {
async charge(amount: number, cardToken: string) {
return { success: cardToken !== "declined-card", transactionId: "test-tx-123" };
}
}
test("placeOrder marks order as paid on successful charge", async () => {
const service = new OrderService(new FakeOrderRepository(), new FakePaymentGateway());
const order = { id: "1", total: 50, status: "pending" } as Order;
await service.placeOrder(order, "valid-card");
expect(order.status).toBe("paid");
});
No test database, no network calls, no mocking framework reaching into module internals. The fake adapters are plain classes implementing the same interface the real ones do, which means the test double is exercising the exact contract the domain actually relies on, not an approximation of it.
The second payoff is real infrastructure swaps becoming genuinely low-risk. Switching payment providers means writing a new adapter and changing one line in main.ts. The domain code, the part with actual business rules and the highest cost if broken, doesn’t move.
Where it costs more than it’s worth
None of this is free. Every port is an interface plus at least one adapter, often more once you add a test double. A small CRUD service, one that validates input, writes to a single table, and returns it, gains almost nothing from this structure, because there’s barely any business logic to protect from infrastructure in the first place. You’ll spend more time writing interfaces and mapping functions than you would have spent just calling the database client directly.
The mapping layer is the other recurring cost. Database rows and API responses rarely match your domain model shape exactly, so adapters usually need explicit translation functions (mapRowToOrder above), which is another piece of code to write and maintain, on top of the interfaces themselves.
Deciding if you need it
| Signal | Suggests hexagonal architecture | Suggests skip it |
|---|---|---|
| Business logic complexity | Real rules, validation, calculations | Mostly pass-through CRUD |
| Infrastructure stability | Multiple providers, or likely to change | One database, one API, unlikely to change |
| Test needs | Fast, isolated unit tests matter | Integration tests already fast enough |
| Team size | Multiple teams touching the same domain | Small team, low coordination overhead |
The pattern doesn’t require an all-or-nothing commitment. Applying ports and adapters to the modules with genuine domain complexity, billing logic, core business rules, anything integrating multiple providers, while leaving simple CRUD endpoints as direct, unlayered code, is a legitimate middle ground. This is the same tradeoff logic behind choosing a caching strategy: the right answer depends on what a specific piece of the system actually needs, not a rule applied uniformly across the whole codebase. The mistake isn’t picking hexagonal architecture or skipping it, it’s applying the same answer everywhere without asking which parts of the system actually carry the complexity that justifies the extra layer.
Frequently asked questions
- What is hexagonal architecture?
- Hexagonal architecture, also called ports and adapters, is a pattern where your core business logic (the domain) defines interfaces, called ports, describing what it needs from the outside world, like 'save this order' or 'charge this card'. Infrastructure code, called adapters, implements those interfaces using specific technology: a Postgres adapter for persistence, a Stripe adapter for payments. The domain never directly imports or depends on any adapter.
- How is this different from a standard layered (MVC-style) architecture?
- In a typical layered setup, a service layer often still imports its ORM models or database client directly, so business logic and persistence logic are tangled even if they're in separate files. In hexagonal architecture, the dependency direction is inverted: the domain defines an interface, and the database code depends on and implements that interface, not the other way around. The domain doesn't know Postgres exists.
- Isn't this just dependency injection with extra steps?
- Dependency injection is the mechanism, hexagonal architecture is the discipline around what gets injected and why. You can use DI everywhere and still have your domain logic directly reference database-specific types. Hexagonal architecture specifically means the domain only ever references its own port interfaces, never a concrete adapter type, anywhere in its code.
- When is hexagonal architecture overkill?
- For a CRUD-heavy application where most endpoints are 'validate input, write to one table, return it', the extra interfaces and mapping layers add real overhead for very little payoff, since there's barely any business logic to protect from infrastructure concerns in the first place. It earns its keep when there's genuine domain complexity, multiple integrations that might change, or a real need to swap infrastructure (different databases per environment, multiple payment processors).
- Can I apply this to only part of my codebase?
- Yes, and for most teams that's the more realistic path. Apply ports and adapters to the modules with real business logic and a plausible reason to swap infrastructure (billing, core domain rules, anything with multiple provider integrations), and leave simple CRUD endpoints as direct controller-to-database code. Architecture patterns aren't all-or-nothing commitments.
Sponsored
More from this category
More from Web Development
R.01 MySQL Now Has a Native VECTOR Type. Should You Drop pgvector For It?
R.02 React 19.2 vs 'React 20': There Is No React 20, and Here's What's Actually New
R.03 Next.js's First Scheduled Security Release Shipped: 9 Advisories, What to Patch
Sponsored
Discussion
Join the conversation.
Comments are powered by GitHub Discussions. Sign in with your GitHub account to leave a comment.
Sponsored