Hexagonal Architecture in 2026: Ports, Adapters, Clean Arch
Arvucore Team
September 22, 2025 · Updated August 26, 2026
15 min read
Hexagonal architecture (also called ports and adapters) puts your business logic in the center of the application and forces every external concern — databases, HTTP, queues, third-party APIs — to talk to it through interfaces the core owns. Clean Architecture and Onion Architecture are later formulations of the same idea with more prescriptive layers. If you remember one rule, it is this: source code dependencies point inward, and the core never imports infrastructure.
This guide explains ports and adapters precisely, compares the three styles side by side, walks through a TypeScript Orders example with in-memory and Postgres adapters, and lists the mistakes that turn the pattern into ceremony.
What hexagonal architecture actually says
Alistair Cockburn described the pattern in 2005 with a simple goal: an application should be equally drivable by users, programs, automated tests and batch scripts, and it should be developable and testable in isolation from its runtime devices and databases. The hexagon shape has no special meaning; it just gives room to draw several sides, one per kind of external actor.
The pattern has three parts:
- The application core. Domain model plus application logic (use cases). It knows nothing about frameworks, transport or storage.
- Ports. Interfaces that define how the core is used and what it needs. Ports belong to the core and are written in the core's language (
placeOrder,OrderRepository), never in the infrastructure's language (insertRow,POST /orders). - Adapters. Code at the edge that translates between a port and a concrete technology. Adapters are replaceable; the core is not supposed to notice when one is swapped.
DRIVING SIDE DRIVEN SIDE
(who calls the application) (what the application calls)
+-----------+ +----------------+
| REST ctrl |--+ +--| Postgres repo |
+-----------+ | | +----------------+
+-----------+ | +--------------------------------+ | +----------------+
| CLI |--+---->| driving port | APPLICATION | +--| In-memory repo |
+-----------+ | | PlaceOrder | CORE | | +----------------+
+-----------+ | | | ------------ | | +----------------+
| Test |--+ | | Order |---->+--| SMTP mailer |
+-----------+ | | OrderLine | | +----------------+
+-----------+ | | | Money | | +----------------+
| Queue |--+ | | driven ports | +--| Payment gateway|
+-----------+ +--------------------------------+ +----------------+
Adapters IMPLEMENT driven ports. Adapters CALL driving ports.
All arrows in source code point INTO the core.
Driving ports vs driven ports
The distinction that most tutorials blur is the direction of the call.
Driving ports (primary, inbound). The outside world calls the core through them. A driving port is the use-case API: PlaceOrder.execute(command). The adapter on this side is a caller — an HTTP controller, a GraphQL resolver, a message consumer, a cron job, a test. The core exposes the port; the adapter depends on it.
Driven ports (secondary, outbound). The core calls the outside world through them. A driven port is a need the core has: OrderRepository, PaymentGateway, Clock, EventPublisher. The adapter on this side is an implementer — a Postgres repository, a Stripe client, a fake for tests. The core declares the interface; the adapter implements it and is injected at startup.
This is dependency inversion applied at the application boundary. The core defines both kinds of port, so in source code the core depends on nothing outside itself. The runtime call in the driven direction goes outward, but the compile-time dependency still points inward because the interface lives in the core.
Two consequences follow:
- Ports are defined in terms of domain types (
Order,OrderId,Money), never in terms of an ORM entity, a DTO for an HTTP framework or a database row. - Anything the core needs from the environment — time, randomness, configuration — is a driven port too. That is what makes it deterministic in tests.
Hexagonal vs Clean vs Onion architecture
All three are variations on the same rule. The differences are in vocabulary, how many layers they prescribe, and where they come from.
| Criterion | Hexagonal (Ports and Adapters) | Onion Architecture | Clean Architecture |
|---|---|---|---|
| Origin | Alistair Cockburn, 2005 | Jeffrey Palermo, 2008 | Robert C. Martin, 2012 |
| Core idea | Core talks to the outside only through ports; adapters translate | Concentric rings around a domain model | Concentric circles with an explicit dependency rule |
| Layers named | Two: inside (core) and outside (adapters). No internal layering prescribed | Domain model, domain services, application services, infrastructure/UI | Entities, use cases, interface adapters, frameworks and drivers |
| Dependency rule | Implicit: adapters depend on ports, the core depends on nothing | Explicit: outer rings depend on inner rings only | Explicit and named: dependencies point inward; inner circles know nothing of outer ones |
| Terminology | Port, adapter, driving/driven, application | Domain model, domain service, application service, infrastructure | Entity, use case (interactor), gateway, presenter, controller |
| Where interfaces live | In the core, owned by the core | In the inner rings (domain or application) | In the use-case circle (input/output boundaries, gateways) |
| Prescribes use cases? | No, but "application" plays the role | Application services | Yes: one interactor per use case, with input/output ports |
| Typical folder structure | domain/, application/, ports/, adapters/ |
Domain/, Application/, Infrastructure/, Web/ |
entities/, usecases/, adapters/, frameworks/ |
| Strongest at | Explaining the boundary and testability | Emphasising the domain model | Standardising the internal layering and naming |
Read the table and you see the pattern: hexagonal answers where is the boundary and who implements it, Onion and Clean answer how do I organise what is inside the boundary. Most production codebases that say "hexagonal" actually use Clean-style internal layers (domain, application/use cases) with hexagonal vocabulary at the edge (ports, adapters). That is a sensible combination, not a contradiction.
Worked example: an Orders module in TypeScript
The example below is small enough to read in one sitting and complete enough to show every part: domain, a driven port, two adapters for it, a use case (driving port), and an HTTP adapter.
Suggested folder structure
src/
orders/
domain/
Order.ts # aggregate with business rules
OrderLine.ts
Money.ts
errors.ts
application/
ports/
driving/
PlaceOrder.ts # use-case interface (driving port)
driven/
OrderRepository.ts # persistence interface (driven port)
Clock.ts
use-cases/
PlaceOrderService.ts # implements PlaceOrder, depends on driven ports
adapters/
driving/
http/
OrdersController.ts
driven/
persistence/
InMemoryOrderRepository.ts
PostgresOrderRepository.ts
SystemClock.ts
composition/
container.ts # wires adapters to ports (composition root)
main.ts
Rule of thumb: domain/ and application/ must compile without adapters/. If you can delete the adapters/ folder and the core still type-checks, the boundary is intact.
Domain
// src/orders/domain/Order.ts
import { Money } from './Money';
import { OrderLine } from './OrderLine';
import { EmptyOrderError } from './errors';
export type OrderId = string;
export class Order {
private constructor(
readonly id: OrderId,
readonly customerId: string,
private readonly lines: OrderLine[],
readonly placedAt: Date,
) {}
static place(id: OrderId, customerId: string, lines: OrderLine[], now: Date): Order {
if (lines.length === 0) throw new EmptyOrderError(id);
return new Order(id, customerId, [...lines], now);
}
total(): Money {
return this.lines.reduce((sum, l) => sum.add(l.subtotal()), Money.zero('EUR'));
}
getLines(): readonly OrderLine[] {
return this.lines;
}
}
The domain has behaviour (place, total) and enforces an invariant (no empty orders). Note that it receives now instead of calling new Date() — time is a dependency.
Driven ports
// src/orders/application/ports/driven/OrderRepository.ts
import { Order, OrderId } from '../../../domain/Order';
export interface OrderRepository {
save(order: Order): Promise<void>;
findById(id: OrderId): Promise<Order | null>;
}
// src/orders/application/ports/driven/Clock.ts
export interface Clock {
now(): Date;
}
The repository speaks in Order, not in rows. It has exactly the methods the use cases need, not a generic CRUD surface.
Driving port and use case
// src/orders/application/ports/driving/PlaceOrder.ts
export interface PlaceOrderCommand {
orderId: string;
customerId: string;
lines: { sku: string; quantity: number; unitPriceCents: number }[];
}
export interface PlaceOrder {
execute(command: PlaceOrderCommand): Promise<{ orderId: string; totalCents: number }>;
}
// src/orders/application/use-cases/PlaceOrderService.ts
import { PlaceOrder, PlaceOrderCommand } from '../ports/driving/PlaceOrder';
import { OrderRepository } from '../ports/driven/OrderRepository';
import { Clock } from '../ports/driven/Clock';
import { Order } from '../../domain/Order';
import { OrderLine } from '../../domain/OrderLine';
import { Money } from '../../domain/Money';
export class PlaceOrderService implements PlaceOrder {
constructor(
private readonly orders: OrderRepository,
private readonly clock: Clock,
) {}
async execute(cmd: PlaceOrderCommand) {
const lines = cmd.lines.map(
(l) => new OrderLine(l.sku, l.quantity, Money.of(l.unitPriceCents, 'EUR')),
);
const order = Order.place(cmd.orderId, cmd.customerId, lines, this.clock.now());
await this.orders.save(order);
return { orderId: order.id, totalCents: order.total().cents };
}
}
The use case orchestrates: build domain objects, apply rules, persist through a port, return a plain result. It imports only from domain/ and ports/.
Two adapters for the same port
// src/orders/adapters/driven/persistence/InMemoryOrderRepository.ts
import { OrderRepository } from '../../../application/ports/driven/OrderRepository';
import { Order, OrderId } from '../../../domain/Order';
export class InMemoryOrderRepository implements OrderRepository {
private readonly store = new Map<OrderId, Order>();
async save(order: Order) { this.store.set(order.id, order); }
async findById(id: OrderId) { return this.store.get(id) ?? null; }
}
// src/orders/adapters/driven/persistence/PostgresOrderRepository.ts
import { Pool } from 'pg';
import { OrderRepository } from '../../../application/ports/driven/OrderRepository';
import { Order, OrderId } from '../../../domain/Order';
import { toRows, fromRows } from './OrderMapper';
export class PostgresOrderRepository implements OrderRepository {
constructor(private readonly pool: Pool) {}
async save(order: Order) {
const { header, lines } = toRows(order);
const client = await this.pool.connect();
try {
await client.query('BEGIN');
await client.query(
'INSERT INTO orders (id, customer_id, placed_at) VALUES ($1, $2, $3)',
[header.id, header.customer_id, header.placed_at],
);
for (const l of lines) {
await client.query(
'INSERT INTO order_lines (order_id, sku, quantity, unit_price_cents) VALUES ($1, $2, $3, $4)',
[l.order_id, l.sku, l.quantity, l.unit_price_cents],
);
}
await client.query('COMMIT');
} catch (e) {
await client.query('ROLLBACK');
throw e;
} finally {
client.release();
}
}
async findById(id: OrderId) {
const header = await this.pool.query('SELECT * FROM orders WHERE id = $1', [id]);
if (header.rowCount === 0) return null;
const lines = await this.pool.query('SELECT * FROM order_lines WHERE order_id = $1', [id]);
return fromRows(header.rows[0], lines.rows);
}
}
The mapping between Order and table rows lives in the adapter (OrderMapper). The domain never sees pg, and the SQL never leaks upward. Swapping Postgres for DynamoDB or an ORM is a new file in adapters/, not a change in application/.
HTTP adapter (driving)
// src/orders/adapters/driving/http/OrdersController.ts
import { FastifyInstance } from 'fastify';
import { PlaceOrder } from '../../../application/ports/driving/PlaceOrder';
import { EmptyOrderError } from '../../../domain/errors';
export function registerOrderRoutes(app: FastifyInstance, placeOrder: PlaceOrder) {
app.post('/orders', async (req, reply) => {
try {
const result = await placeOrder.execute(req.body as any); // validate with a schema in real code
return reply.code(201).send(result);
} catch (e) {
if (e instanceof EmptyOrderError) return reply.code(422).send({ error: e.message });
throw e;
}
});
}
The controller translates HTTP into a command and domain errors into status codes. It depends on the PlaceOrder interface, not on PlaceOrderService, so the same routes work against any implementation.
Composition root
// src/composition/container.ts
import { Pool } from 'pg';
import { PlaceOrderService } from '../orders/application/use-cases/PlaceOrderService';
import { PostgresOrderRepository } from '../orders/adapters/driven/persistence/PostgresOrderRepository';
import { SystemClock } from '../orders/adapters/driven/SystemClock';
export function buildContainer(env: { DATABASE_URL: string }) {
const pool = new Pool({ connectionString: env.DATABASE_URL });
const placeOrder = new PlaceOrderService(new PostgresOrderRepository(pool), new SystemClock());
return { placeOrder };
}
This is the only place that knows both sides. Frameworks with DI containers (NestJS, tsyringe, Spring, .NET) do the same job; a plain function is enough for most modules.
Testing: the reason most teams adopt it
With the boundary in place, tests split into three tiers, each with a clear job.
Core tests, no I/O. Wire the use case to the in-memory repository and a fixed clock. They run in milliseconds, need no database or container, and are the ones you write most of.
// PlaceOrderService.test.ts
const orders = new InMemoryOrderRepository();
const clock = { now: () => new Date('2026-08-26T10:00:00Z') };
const service = new PlaceOrderService(orders, clock);
it('rejects an order without lines', async () => {
await expect(service.execute({ orderId: 'o1', customerId: 'c1', lines: [] }))
.rejects.toBeInstanceOf(EmptyOrderError);
});
it('persists the order and returns the total', async () => {
const result = await service.execute({
orderId: 'o2', customerId: 'c1',
lines: [{ sku: 'A', quantity: 2, unitPriceCents: 1500 }],
});
expect(result.totalCents).toBe(3000);
expect(await orders.findById('o2')).not.toBeNull();
});
Adapter contract tests. Run the same suite against every implementation of a driven port. A shared describeOrderRepositoryContract(factory) executed once for InMemoryOrderRepository and once for PostgresOrderRepository (against a real Postgres in a container) guarantees the fake behaves like production. This is the step that keeps in-memory fakes honest.
Thin end-to-end tests. A handful through the HTTP adapter to prove the wiring. Not one per business rule; the rules are already covered in tier one.
The practical result is a fast CI feedback loop and a test suite that survives an infrastructure migration untouched. For the broader case, see TDD business benefits.
Common mistakes
Anemic domain. Entities that are bags of getters and setters, with all logic in "services". You then have a hexagon protecting nothing. If Order cannot compute its own total or reject an empty line list, the boundary is expensive decoration. Push rules into the domain objects; keep use cases as orchestration.
Leaking ORM entities through ports. A port whose signature is save(entity: PrismaOrder) or findById(): Promise<TypeOrmOrderEntity> has made the core depend on the database library. The fix is a mapper inside the adapter and domain types in the port, as in the example above. The same applies to HTTP DTOs on the driving side: the use case receives a command, not a request object.
Over-abstracting a CRUD app. A form that writes a row and reads it back does not need a use case, two ports and three adapters. You get four files per field and no protection, because there are no rules to protect. Reserve the pattern for modules with real logic.
Generic repository interfaces. Repository<T> with findAll, findWhere, count invites the application layer to write queries. Ports should list the operations the use cases actually need, named in domain terms.
Framework in the core. Decorators from the web framework on use cases, @Injectable() on domain classes, framework exceptions crossing the port. Some teams accept a DI decorator in application/; none should accept it in domain/.
One hexagon for the whole system. A single application/ folder with 200 use cases is a layered monolith with new names. Draw one hexagon per bounded context or module, as discussed below.
When not to use hexagonal architecture
A decision checklist. If most answers are "no", a simpler layered structure will serve you better.
- Does the module contain business rules beyond input validation and persistence?
- Is it plausible that a driven dependency (database, payment provider, messaging) changes during the product's life?
- Do you need fast tests that run without infrastructure?
- Will more than one driver call the same logic (HTTP and a queue, or HTTP and a CLI)?
- Will the code live long enough for maintainability to matter more than initial speed?
- Does the team understand dependency inversion, or is there time to teach it?
Skip the pattern for prototypes with a known short life, internal admin CRUD, thin proxies and glue services, and scripts. Also skip it if the team will not enforce the boundary; a hexagon with import { Pool } from 'pg' inside domain/ is worse than an honest layered app, because it promises isolation that does not exist. Lint rules (for example, ESLint no-restricted-imports per folder, or dependency-cruiser) are cheap insurance.
How it fits with DDD, microservices and modular monoliths
With Domain-Driven Design. DDD supplies the content of the hexagon: aggregates, value objects, domain events, a ubiquitous language. Hexagonal supplies the shell that keeps that model free of infrastructure. Bounded contexts map naturally to hexagons; anti-corruption layers are just adapters between two contexts. See Domain-Driven Design in practice for the modelling side.
With modular monoliths. This is the sweet spot in 2026 for most mid-sized products. Each module (orders/, billing/, catalog/) is a hexagon with its own ports and adapters, deployed as one process. Modules talk to each other only through driving ports (or published events), never through each other's repositories. Because the boundaries exist in code, extracting a module into a service later is mostly a change in the composition root and the adapters.
With microservices. Each service is a hexagon. The pattern keeps the service's core independent from the transport (REST today, gRPC or events tomorrow) and makes the in-process tests fast, which matters more in a distributed system where end-to-end tests are slow and flaky. It does not solve distributed concerns — consistency, contracts between services, observability — which are covered in microservices vs monolithic architecture and microservices testing strategies.
With event-driven systems. A message consumer is a driving adapter; an event publisher is a driven port. The core emits domain events; an adapter maps them to Kafka, SNS or an outbox table. That mapping is the place to handle serialization and versioning, not the domain. More on the pattern in event-driven architecture.
Recommendation
Use hexagonal architecture per module, not per system, and only for modules with real business rules. Combine it with Clean Architecture's internal split (domain and application/use cases) and keep the hexagonal vocabulary at the edge (driving and driven ports, adapters). Define ports in domain types, map to ORM or transport types inside adapters, and write a contract test that runs against every adapter of a port so your fakes stay truthful. Enforce the import direction with tooling from day one.
Start with a modular monolith in which each module is a hexagon; you get most of the testability and replaceability of microservices without the operational cost, and the door to extraction stays open. At Arvucore we usually recommend this shape for products expected to live more than a couple of years, and a plain layered structure for everything else.
Ready to Transform Your Business?
Let's discuss how our solutions can help you achieve your goals. Get in touch with our experts today.
Talk to an ExpertTags:
Arvucore Team
Arvucore’s editorial team is formed by experienced professionals in software development. We are dedicated to producing and maintaining high-quality content that reflects industry best practices and reliable insights.
Frequently asked questions
- What is the difference between hexagonal architecture and clean architecture?
- They share the same core idea: business logic in the center, dependencies pointing inward, infrastructure at the edge. Hexagonal (2005) describes two kinds of boundary, ports and adapters, and says nothing about internal layers. Clean Architecture (2012) prescribes concentric layers (entities, use cases, interface adapters, frameworks) and names the dependency rule explicitly. In practice most teams implement a blend.
- What is a port and what is an adapter?
- A port is an interface owned by the application core that describes how the outside world talks to it (driving port) or how it talks to the outside world (driven port). An adapter is the concrete code that implements or calls that interface: an HTTP controller, a CLI, a Postgres repository, an SMTP client.
- What is the difference between driving and driven ports?
- Driving (primary) ports are called by the outside world to trigger the application, for example a PlaceOrder use case invoked by a REST controller. Driven (secondary) ports are called by the application to reach the outside world, for example an OrderRepository implemented by a database adapter.
- Is hexagonal architecture overkill for a CRUD app?
- Usually yes. If the application has no business rules beyond validation and persistence, ports and adapters add indirection without protecting anything. Use it when the domain logic is worth isolating and when you expect infrastructure to change or need fast tests without I/O.
- Does hexagonal architecture require DDD?
- No. Hexagonal architecture is about where the boundary sits; DDD is about what goes inside it. They combine well, since DDD gives you a rich domain model worth protecting, but you can use ports and adapters with a simple domain.
- Does hexagonal architecture work with microservices?
- Yes, and with modular monoliths. Each service or module gets its own hexagon. The pattern makes it easier to move a module out to a separate service later because the boundary already exists in code.
Related articles

Software Architecture: Domain-Driven Design in Practice
Domain-driven design (DDD) offers a pragmatic approach to modeling complex business domains within software architecture. This article from Arvucore explains practical DDD implementation strategies, patterns and trade-offs for teams facing complex software architecture challenges. It guides technical leaders and decision makers through bounded contexts, tactical patterns, and organizational alignment to deliver maintainable, business-aligned systems today.

Event-Driven Architecture: Resilient and Scalable Systems
At Arvucore, we explore how event-driven architecture transforms modern distributed systems, enabling responsive, resilient, and scalable applications. This article examines core principles, practical design patterns, and operational considerations for implementing event-driven systems across enterprise environments. Readers will find guidance on architecture choices, integration strategies, and measurable benefits to support business agility and technical robustness in production deployments.

GraphQL vs REST in 2026: When to Use Each API Style
GraphQL vs REST compared on fetching, caching, versioning, errors, N+1, security and tooling, with a decision checklist by scenario and a side-by-side example.

Database Design: SQL vs NoSQL for Enterprise Applications
This article from Arvucore examines enterprise database design and the sql nosql choice for modern database applications. We compare architectural trade-offs, performance, scalability, and operational concerns to help European business decision makers and technical teams choose the right approach. Practical guidance and real-world considerations focus on maintainability, cost, and integration across existing enterprise ecosystems. We prioritize actionable, evidence-based insights today.