Hexagonal Architecture
Credit
Original skill by ECC.
This version keeps the same goal and gives full credit to ECC.
Goal
Keep business rules apart from tools such as web frameworks, databases, queues, and vendor SDKs.
The app core defines what it needs. Code at the edge does the real I/O work.
Main Parts
- Domain: Business data and rules. Do not import web, database, or SDK code.
- Use case: Runs one app task, such as creating an order.
- Inbound port: Says what the app can do.
- Outbound port: Says what the app needs from outside.
- Inbound adapter: Turns HTTP, CLI, queue, or job input into use-case input.
- Outbound adapter: Connects a port to a database, API, queue, clock, or other tool.
- Composition root: The one place that creates and joins all parts.
Keep all links aimed at the core:
HTTP, CLI, worker
|
v
inbound adapter
|
v
use case <---- port <---- outbound adapter <---- database or API
|
v
domainSteps
1. Find the use case
Name one clear task, such as CreateOrder.
Define plain input and output data. Do not pass HTTP requests, ORM rows, queue messages, or SDK types into the use case.
2. Find side effects
List each thing outside the app core:
- Saving or reading data
- Calling an API
- Sending a message
- Reading the time
- Making an ID
- Writing a log
Add an outbound port when the core must use one of these things.
Name ports by what they do, such as OrderRepository. Do not name them after tools, such as PostgresService.
Do not make a port for a small pure helper. A port is useful when code does I/O, changes often, or must be replaced in tests.
3. Write the core
Pass ports into the use case through its constructor or function args.
The use case should:
- Check app rules.
- Call domain rules.
- Use ports for side effects.
- Return plain data.
It must not create database clients or vendor SDK clients.
4. Write adapters
An inbound adapter must:
- Read protocol input.
- Check its shape and basic types.
- Turn it into use-case input.
- Call the use case.
- Turn the result or error into a protocol reply.
An outbound adapter must:
- Implement one outbound port.
- Map core data to tool data.
- Map tool data back to core data.
- Keep ORM rows and SDK types out of the core.
5. Join the parts
Create adapters first. Pass them into use cases in one clear setup file.
Do not use hidden global state or a service locator.
6. Test each line
- Test domain rules with no I/O.
- Test use cases with fake ports.
- Test each adapter with its real tool when safe.
- Test key user flows from the inbound edge.
Hard Cases
Errors
Define errors the core can act on, such as OrderNotFound or PaymentDeclined.
Adapters must map tool errors to these app errors. Do not leak raw SQL or SDK errors into the core.
Inbound adapters must map app errors to safe replies. Do not send secret or private error data to users.
Transactions
Keep a transaction around one full app action when several writes must all pass or all fail.
Define a unit-of-work port if the use case must control that line. Do not put business rules inside database transaction code.
Retries
Retry only work that is safe to run again.
Use an idempotency key for payments, messages, and other work that must not happen twice. Put retry rules at the edge unless retry timing is a business rule.
Events
Create domain events in the core when a business fact has happened.
Use an outbound port to publish them. If saving data and sending an event must stay in sync, use an outbox or a similar safe write plan.
Reads
Simple read pages may use a query port that returns a plain view model.
Do not force every read through a rich domain object when no business rule needs it.
Old Code
Refactor one use case at a time.
Place an adapter around old code first. Move rules into the core in small steps. Keep old and new paths covered by tests.
Small Apps
Do not add layers with no clear need.
A small app may use functions and a few interfaces. The key rule is still the same: business rules must not depend on I/O tools.
Suggested Layout
src/
features/
orders/
domain/
Order.ts
application/
ports/
OrderRepository.ts
PaymentGateway.ts
CreateOrder.ts
adapters/
http/
createOrderRoute.ts
postgres/
PostgresOrderRepository.ts
payments/
StripePaymentGateway.ts
composition/
buildOrders.tsUse names and folders that fit the codebase. Keep the same clear lines even if the folders differ.
Concrete TypeScript Example
type CreateOrderInput = {
orderId: string;
amountCents: number;
};
type CreateOrderOutput = {
orderId: string;
paymentId: string;
};
interface OrderRepository {
findById(id: string): Promise<Order | null>;
save(order: Order): Promise<void>;
}
interface PaymentGateway {
charge(input: {
orderId: string;
amountCents: number;
}): Promise<{ paymentId: string }>;
}
class CreateOrder {
constructor(
private readonly orders: OrderRepository,
private readonly payments: PaymentGateway
) {}
async run(input: CreateOrderInput): Promise<CreateOrderOutput> {
if (input.amountCents <= 0) {
throw new Error("Order amount must be above zero");
}
const oldOrder = await this.orders.findById(input.orderId);
if (oldOrder) {
throw new Error("Order already exists");
}
const order = Order.create(input.orderId, input.amountCents);
const payment = await this.payments.charge({
orderId: order.id,
amountCents: order.amountCents,
});
const paidOrder = order.markPaid(payment.paymentId);
await this.orders.save(paidOrder);
return {
orderId: paidOrder.id,
paymentId: payment.paymentId,
};
}
}An HTTP adapter can call the use case like this:
async function createOrderRoute(req: Request, res: Response) {
const input = {
orderId: String(req.body.orderId),
amountCents: Number(req.body.amountCents),
};
try {
const result = await createOrder.run(input);
res.status(201).json(result);
} catch (error) {
res.status(400).json({ error: "Could not create order" });
}
}The setup file joins the parts:
function buildCreateOrder(deps: {
db: SqlClient;
paymentClient: PaymentClient;
}) {
const orders = new PostgresOrderRepository(deps.db);
const payments = new PaymentApiAdapter(deps.paymentClient);
return new CreateOrder(orders, payments);
}A use-case test uses fake ports:
const saved: Order[] = [];
const fakeOrders: OrderRepository = {
findById: async () => null,
save: async (order) => {
saved.push(order);
},
};
const fakePayments: PaymentGateway = {
charge: async () => ({ paymentId: "pay-1" }),
};
const useCase = new CreateOrder(fakeOrders, fakePayments);
const result = await useCase.run({
orderId: "order-1",
amountCents: 2500,
});
expect(result.paymentId).toBe("pay-1");
expect(saved).toHaveLength(1);Final Check
Before finishing, check that:
- Domain code has no framework, database, queue, or vendor imports.
- Use cases take plain input and return plain output.
- Side effects go through clear outbound ports.
- Adapters own all data mapping.
- Tool errors do not leak into the core.
- Setup code lives in one known place.
- Use cases can run with fake ports.
- Retry and duplicate-call risks are handled.
- Transaction lines match the full app action.
- New layers solve a real need.