Ports and adapters in TypeScript
An order use case with two ports, OrderRepository and PaymentGateway, an in-memory adapter, a Postgres adapter, and the composition root that wires them.
Series · 2/3Clean Architecture without dogma
- 01The dependency rule, without dogma
- 02Ports and adapters in TypeScript
- 03Testing the domain and use cases without mocks
The previous article left one rule. Inner code does not import outer code. The practical part is still missing. A use case has to store an order and charge for it, and both are I/O. This article builds those two edges in TypeScript, with the full code of each piece and the decisions I made in each one.
What a port is and what an adapter is
A port is an interface that the application defines, in the application’s vocabulary. An adapter is a concrete implementation of that port that talks to a technology. The use case knows the port. The adapter knows the port and the technology. The composition root knows both and wires them.
This example has two outbound ports, OrderRepository and PaymentGateway. The HTTP controller that invokes the use case is also an adapter, an inbound one, but it needs no interface of its own. It calls PlaceOrder directly.
The domain, a bit more complete
The Order from the first article gains a status and two methods to move in and out of persistence as plain data.
export type OrderLine = { sku: string; quantity: number; unitPriceCents: number };
export type OrderStatus = 'pending' | 'paid' | 'payment-failed';
export type OrderSnapshot = {
id: string;
customerId: string;
lines: OrderLine[];
status: OrderStatus;
chargeId: string | null;
};
export class Order {
private constructor(private state: OrderSnapshot) {}
static create(id: string, customerId: string, lines: readonly OrderLine[]): Order {
if (lines.length === 0) throw new Error('An order needs at least one line');
return new Order({ id, customerId, lines: [...lines], status: 'pending', chargeId: null });
}
static fromSnapshot(snapshot: OrderSnapshot): Order {
return new Order(structuredClone(snapshot));
}
toSnapshot(): OrderSnapshot {
return structuredClone(this.state);
}
get id(): string { return this.state.id; }
get status(): OrderStatus { return this.state.status; }
totalCents(): number {
return this.state.lines.reduce((sum, l) => sum + l.quantity * l.unitPriceCents, 0);
}
markPaid(chargeId: string): void {
this.assertPending();
this.state = { ...this.state, status: 'paid', chargeId };
}
markPaymentFailed(): void {
this.assertPending();
this.state = { ...this.state, status: 'payment-failed' };
}
private assertPending(): void {
if (this.state.status !== 'pending') {
throw new Error(`Order ${this.state.id} is already ${this.state.status}`);
}
}
}OrderSnapshot is the contract between the domain and any persistence adapter. Both methods clone, so nothing outside can mutate an order’s internal state.
The ports
Each port lives in src/application/ports/ and exposes only what the use case needs.
import type { Order } from '../../domain/order.js';
export interface OrderRepository {
save(order: Order): Promise<void>;
findById(id: string): Promise<Order | undefined>;
}export type ChargeRequest = { reference: string; amountCents: number; token: string };
export type ChargeResult =
| { status: 'approved'; chargeId: string }
| { status: 'declined'; reason: string };
export interface PaymentGateway {
charge(request: ChargeRequest): Promise<ChargeResult>;
}A card decline is a business outcome, so it is a value of the return type. A timeout or a 503 from the provider is a technical failure and comes out as an exception. Separating the two in the type forces the use case to handle the decline and frees it from knowing what a 503 is.
The use case
import { Order, type OrderLine } from '../domain/order.js';
import type { OrderRepository } from './ports/order-repository.js';
import type { PaymentGateway } from './ports/payment-gateway.js';
export type PlaceOrderInput = {
orderId: string;
customerId: string;
lines: OrderLine[];
paymentToken: string;
};
export type PlaceOrderResult =
| { kind: 'placed'; orderId: string; chargeId: string }
| { kind: 'payment-declined'; orderId: string; reason: string };
export class PlaceOrder {
constructor(
private readonly orders: OrderRepository,
private readonly payments: PaymentGateway,
) {}
async execute(input: PlaceOrderInput): Promise<PlaceOrderResult> {
const order = Order.create(input.orderId, input.customerId, input.lines);
await this.orders.save(order);
const charge = await this.payments.charge({
reference: order.id,
amountCents: order.totalCents(),
token: input.paymentToken,
});
if (charge.status === 'declined') {
order.markPaymentFailed();
await this.orders.save(order);
return { kind: 'payment-declined', orderId: order.id, reason: charge.reason };
}
order.markPaid(charge.chargeId);
await this.orders.save(order);
return { kind: 'placed', orderId: order.id, chargeId: charge.chargeId };
}
}- Imports
Only the domain and ports. Every port import is
import type, so it disappears from the compiled JavaScript. - Input and output
The result is a discriminated union. There are no status codes. The controller decides that
payment-declinedis a402, and a queue consumer decides something else. - Explicit dependencies
The constructor receives both ports. There is no
newof adapters and no injection container. Whoever buildsPlaceOrderdecides which implementation to use. - Store before charging
The order is saved as
pendingbefore the charge. If the process dies after charging, there is a record to reconcile. Thereferenceis the order id, which the provider can use to detect duplicates. - The two exits
Each branch changes the state with a domain method and saves. The rule that only a
pendingorder can be paid lives inOrder, not here.
The orderId arrives in the input instead of being generated inside. That keeps the use case deterministic, the tests do not depend on crypto.randomUUID(), and the client can retry with the same id.
Two adapters for the same port
import { Order, type OrderSnapshot } from '../../domain/order.js';
import type { OrderRepository } from '../../application/ports/order-repository.js';
export class InMemoryOrderRepository implements OrderRepository {
private readonly rows = new Map<string, OrderSnapshot>();
async save(order: Order): Promise<void> {
this.rows.set(order.id, order.toSnapshot());
}
async findById(id: string): Promise<Order | undefined> {
const snapshot = this.rows.get(id);
return snapshot ? Order.fromSnapshot(snapshot) : undefined;
}
}It stores snapshots, not Order instances. If it stored the instance, a test could mutate the order after save and see the change reflected in the repository, something Postgres would never do.
import type { Pool } from 'pg';
import { Order, type OrderSnapshot } from '../../domain/order.js';
import type { OrderRepository } from '../../application/ports/order-repository.js';
type OrderRow = {
id: string;
customer_id: string;
lines: OrderSnapshot['lines'];
status: OrderSnapshot['status'];
charge_id: string | null;
};
export class PostgresOrderRepository implements OrderRepository {
constructor(private readonly pool: Pool) {}
async save(order: Order): Promise<void> {
const snapshot = order.toSnapshot();
await this.pool.query(
`INSERT INTO orders (id, customer_id, lines, status, charge_id)
VALUES ($1, $2, $3, $4, $5)
ON CONFLICT (id) DO UPDATE SET status = EXCLUDED.status, charge_id = EXCLUDED.charge_id`,
[snapshot.id, snapshot.customerId, JSON.stringify(snapshot.lines), snapshot.status, snapshot.chargeId],
);
}
async findById(id: string): Promise<Order | undefined> {
const { rows } = await this.pool.query<OrderRow>(
'SELECT id, customer_id, lines, status, charge_id FROM orders WHERE id = $1',
[id],
);
const row = rows[0];
if (!row) return undefined;
return Order.fromSnapshot({
id: row.id,
customerId: row.customer_id,
lines: row.lines,
status: row.status,
chargeId: row.charge_id,
});
}
}The upsert makes save work for both create and update, just like the Map. The OrderRow type trusts the schema. If other services write to the table, validate the row before you build the snapshot.
Both adapters satisfy the same interface and the same observable behavior. The compiler does not guarantee the second part. A contract test suite that runs against both does, and it is the topic of the next article.
Wiring it all
-
Define the port in
src/application/ports/with the methods the use case asks for, and nothing else. -
Write the in-memory adapter first. It is short and lets you test the use case the same day.
-
Write the real adapter in
src/infrastructure/. The payments one,HttpPaymentGateway, translatesChargeRequestinto the provider’s request and maps its response toChargeResult. -
Wire the adapters in the composition root. It is the only file that imports from every layer.
src/main.tstsimport Fastify from 'fastify'; import { Pool } from 'pg'; import { PlaceOrder } from './application/place-order.js'; import { registerOrderRoutes } from './infrastructure/http/order-routes.js'; import { HttpPaymentGateway } from './infrastructure/payments/http-payment-gateway.js'; import { PostgresOrderRepository } from './infrastructure/postgres/postgres-order-repository.js'; function requiredEnv(name: string): string { const value = process.env[name]; if (!value) throw new Error(`Missing environment variable ${name}`); return value; } const pool = new Pool({ connectionString: requiredEnv('DATABASE_URL') }); const placeOrder = new PlaceOrder( new PostgresOrderRepository(pool), new HttpPaymentGateway(requiredEnv('PAYMENTS_URL'), requiredEnv('PAYMENTS_API_KEY')), ); const app = Fastify({ logger: true }); registerOrderRoutes(app, { placeOrder }); await app.listen({ host: '0.0.0.0', port: 3000 });
I did not use a dependency injection container. With about ten use cases, manual wiring fits on one screen and the compiler tells you when an argument is missing. A container starts to pay off when the graph is large or when you need per-request scopes.
What it costs
- The use case is tested with in-memory adapters, in milliseconds
- Switching payment providers touches one file and the composition root
- Technical errors and business outcomes stay separate in the types
- The composition root shows the full dependency graph in one file
- More files for every edge with I/O
- Mapping between
OrderRowandOrderSnapshotis code you have to maintain - Transactions that span several repositories need their own design, for example a
UnitOfWorkport - A fake that diverges from the real adapter gives green tests and production bugs
The last con is the most dangerous and the least discussed. An in-memory adapter that accepts things Postgres rejects hides errors until production. That is why contract tests are not optional.
Summary
- A port uses the application’s vocabulary. If it exposes SQL or HTTP, it is a driver wrapper.
- Business outcomes go in the return type. Technical failures come out as exceptions.
- The in-memory adapter stores copies. Shared references give tests that pass by accident.
- A hand-wired composition root is enough for most services.
- Every adapter has to pass the same contract suite. That is what comes next in the series.