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.

Author
José Morales
Published
Reading
8 min
Series · 2/3Clean Architecture without dogma
  1. 01The dependency rule, without dogma
  2. 02Ports and adapters in TypeScript
  3. 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.

src/domain/order.tsts
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.

src/application/ports/order-repository.tsts
import type { Order } from '../../domain/order.js';

export interface OrderRepository {
  save(order: Order): Promise<void>;
  findById(id: string): Promise<Order | undefined>;
}
src/application/ports/payment-gateway.tsts
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

src/application/place-order.tsts
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 };
  }
}
step 1/5
  1. Imports

    Only the domain and ports. Every port import is import type, so it disappears from the compiled JavaScript.

  2. Input and output

    The result is a discriminated union. There are no status codes. The controller decides that payment-declined is a 402, and a queue consumer decides something else.

  3. Explicit dependencies

    The constructor receives both ports. There is no new of adapters and no injection container. Whoever builds PlaceOrder decides which implementation to use.

  4. Store before charging

    The order is saved as pending before the charge. If the process dies after charging, there is a record to reconcile. The reference is the order id, which the provider can use to detect duplicates.

  5. The two exits

    Each branch changes the state with a domain method and saves. The rule that only a pending order can be paid lives in Order, 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

src/infrastructure/memory/in-memory-order-repository.tsts
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.

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

  1. Define the port in src/application/ports/ with the methods the use case asks for, and nothing else.

  2. Write the in-memory adapter first. It is short and lets you test the use case the same day.

  3. Write the real adapter in src/infrastructure/. The payments one, HttpPaymentGateway, translates ChargeRequest into the provider’s request and maps its response to ChargeResult.

  4. Wire the adapters in the composition root. It is the only file that imports from every layer.

    src/main.tsts
    import 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

tradeoffsPorts and adapters
pros
  • 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
cons
  • More files for every edge with I/O
  • Mapping between OrderRow and OrderSnapshot is code you have to maintain
  • Transactions that span several repositories need their own design, for example a UnitOfWork port
  • 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.

Written by

José Morales

Senior Backend Engineer

Backend and BFFs in production. I write about architecture, distributed systems, and the craft of writing code others can maintain.