A checkout service often starts as one route, one ORM model, and one call to a payment SDK. Six months later, changing the payment provider means editing business rules, tests need a real database, and a harmless framework upgrade touches half the repository. The problem is not that the team chose the wrong framework. The problem is that the framework became the architecture.

Hexagonal architecture, also called ports and adapters, offers a different center of gravity. Put the application’s decisions in the middle. Describe what the application accepts and what it needs as explicit interfaces. Keep HTTP, SQL, queues, clocks, and vendor SDKs outside those interfaces. The result is not a perfect hexagon or a prescribed folder tree; it is a dependency rule that lets business behavior survive infrastructure change.

This article builds that rule around a small order-placement use case and follows it through implementation, testing, failure handling, and the tradeoffs teams encounter in real systems.

The Boundary Is the Point

Traditional layered diagrams usually put controllers above services, services above repositories, and the database at the bottom. That can work, but the visual stack encourages dependencies to point downward. A service imports an ORM repository, returns framework response objects, and reads the system clock directly. Although the folders look separated, the core still knows how the outside world works.

Hexagonal architecture changes the question from “Which layer calls the next layer?” to “Which side owns this contract?” The application owns interfaces that express its needs. Infrastructure implements those interfaces. Dependencies therefore point inward, toward policy.

flowchart LR HTTP[HTTP controller] -->|inbound adapter| API[PlaceOrder port] Worker[Queue consumer] -->|inbound adapter| API API --> UC[PlaceOrder use case] UC --> Domain[Order domain model] UC --> PAY[Payment port] UC --> STORE[Order repository port] UC --> CLOCK[Clock port] PAY --> Stripe[Stripe adapter] STORE --> Postgres[PostgreSQL adapter] CLOCK --> SystemClock[System clock adapter]

The arrows matter more than the shapes. The use case knows PaymentPort; it does not know Stripe. The PostgreSQL adapter imports the application’s OrderRepository contract, while the application never imports the adapter. At runtime, composition connects them. At compile time, policy remains independent.

Note

“Inside” and “outside” describe dependency direction, not network location. A domain module in another process may still be conceptually inside, while an in-process ORM is infrastructure.

The domain model holds rules that are true regardless of delivery mechanism: an order cannot be empty, totals cannot be negative, and a captured payment authorizes confirmation. The application layer coordinates a scenario: load data, invoke domain behavior, request payment, persist the result. Adapters translate between those concepts and technologies.

Ports Have Direction and Intent

A port is an application-facing contract. Its direction is named from the application’s perspective, not from the direction of a network packet.

Inbound ports describe operations the application offers. They are use-case APIs such as PlaceOrder, CancelOrder, or GetAccountBalance. An HTTP controller, CLI command, scheduled job, or message consumer invokes them. Input types should speak the application’s language rather than expose Express requests or Kafka records.

Outbound ports describe capabilities the application requires. Repositories, payment gateways, message publishers, ID generators, and clocks are common examples. The use case invokes them; infrastructure supplies implementations.

Concern Inbound port Outbound port Adapter example
Purpose Exposes an application capability Requests an external capability Translates technology to or from a port
Called by Driving adapter Application use case Runtime composition
Owned by Application Application Infrastructure
Typical shape Command/query interface Repository or gateway interface HTTP controller, SQL repository, SDK wrapper
Change pressure Business workflow Business need Framework and vendor APIs

Naming ports after intent prevents generic abstractions from swallowing meaning. PaymentPort.capture() is more useful than a universal ExternalService.execute(). Likewise, a repository should expose operations the use case needs, such as save(order), rather than mirror every ORM query primitive.

Ports are not automatically interfaces in the TypeScript sense. A function type or abstract class can work. What matters is that the contract belongs to the application and does not leak outside-specific types. If PaymentPort returns a Stripe.PaymentIntent, Stripe has already crossed the boundary.

A Practical TypeScript Slice

Consider a use case that validates an order, captures payment, and records the confirmed order. The following example is intentionally substantial enough to show where responsibilities land, while omitting unrelated HTTP and database details.

ts
// application/place-order.ts
export type PlaceOrderCommand = {
  orderId: string;
  customerId: string;
  currency: 'USD' | 'EUR';
  items: ReadonlyArray<{
    productId: string;
    quantity: number;
    unitPriceInCents: number;
  }>;
};

export type PlaceOrderResult =
  | { status: 'placed'; orderId: string; paymentId: string }
  | { status: 'declined'; reason: string };

export interface PlaceOrder {
  execute(command: PlaceOrderCommand): Promise<PlaceOrderResult>;
}

export interface PaymentPort {
  capture(request: {
    orderId: string;
    customerId: string;
    amountInCents: number;
    currency: 'USD' | 'EUR';
  }): Promise<
    | { status: 'captured'; paymentId: string }
    | { status: 'declined'; reason: string }
  >;
}

export interface OrderRepository {
  exists(orderId: string): Promise<boolean>;
  save(order: Order): Promise<void>;
}

export interface Clock {
  now(): Date;
}

export class Order {
  private constructor(
    readonly id: string,
    readonly customerId: string,
    readonly totalInCents: number,
    readonly paymentId: string,
    readonly placedAt: Date,
  ) {}

  static totalFor(command: PlaceOrderCommand): number {
    if (command.items.length === 0) {
      throw new Error('An order must contain at least one item');
    }

    return command.items.reduce((total, item) => {
      if (item.quantity <= 0 || item.unitPriceInCents < 0) {
        throw new Error('Invalid order item');
      }
      return total + item.quantity * item.unitPriceInCents;
    }, 0);
  }

  static place(
    command: PlaceOrderCommand,
    paymentId: string,
    placedAt: Date,
  ): Order {
    const totalInCents = Order.totalFor(command);

    return new Order(
      command.orderId,
      command.customerId,
      totalInCents,
      paymentId,
      placedAt,
    );
  }
}

export class PlaceOrderService implements PlaceOrder {
  constructor(
    private readonly payments: PaymentPort,
    private readonly orders: OrderRepository,
    private readonly clock: Clock,
  ) {}

  async execute(command: PlaceOrderCommand): Promise<PlaceOrderResult> {
    if (await this.orders.exists(command.orderId)) {
      throw new Error(`Order ${command.orderId} already exists`);
    }

    const amountInCents = Order.totalFor(command);
    const payment = await this.payments.capture({
      orderId: command.orderId,
      customerId: command.customerId,
      amountInCents,
      currency: command.currency,
    });

    if (payment.status === 'declined') {
      return payment;
    }

    const order = Order.place(command, payment.paymentId, this.clock.now());
    await this.orders.save(order);

    return {
      status: 'placed',
      orderId: order.id,
      paymentId: payment.paymentId,
    };
  }
}

The inbound port is PlaceOrder. PaymentPort, OrderRepository, and Clock are outbound ports. PlaceOrderService coordinates the transaction, while Order protects domain invariants. None imports an HTTP framework, an ORM, or a payment package.

An adapter performs translation at the edge:

ts
// infrastructure/stripe-payment-adapter.ts
import type { PaymentPort } from '../application/place-order.js';

type StripeClient = {
  paymentIntents: {
    create(input: {
      amount: number;
      currency: string;
      metadata: Record<string, string>;
    }): Promise<{ id: string; status: string }>;
  };
};

export class StripePaymentAdapter implements PaymentPort {
  constructor(private readonly stripe: StripeClient) {}

  async capture(request: Parameters<PaymentPort['capture']>[0]) {
    const intent = await this.stripe.paymentIntents.create({
      amount: request.amountInCents,
      currency: request.currency.toLowerCase(),
      metadata: {
        orderId: request.orderId,
        customerId: request.customerId,
      },
    });

    return intent.status === 'succeeded'
      ? ({ status: 'captured', paymentId: intent.id } as const)
      : ({ status: 'declined', reason: intent.status } as const);
  }
}

The adapter absorbs Stripe’s naming and status model. If the vendor later adds statuses, only this mapping needs to decide how they appear to the application. The composition root creates the Stripe client, repository, clock, and service. It is the one place allowed to know all concrete types.

Testing Behavior Without Pretending Infrastructure Is Simple

Ports make fast tests possible because the use case can run with in-memory adapters. A test can provide a deterministic clock, a payment stub, and an order repository implemented with a Map. It then asserts business outcomes rather than HTTP status codes or SQL rows.

ts
const saved: Order[] = [];
const service = new PlaceOrderService(
  {
    capture: async () => ({
      status: 'captured',
      paymentId: 'pay-123',
    }),
  },
  {
    exists: async () => false,
    save: async (order) => {
      saved.push(order);
    },
  },
  { now: () => new Date('2025-06-18T10:00:00Z') },
);

const result = await service.execute({
  orderId: 'order-42',
  customerId: 'customer-7',
  currency: 'USD',
  items: [{ productId: 'book', quantity: 2, unitPriceInCents: 2500 }],
});

expect(result).toEqual({
  status: 'placed',
  orderId: 'order-42',
  paymentId: 'pay-123',
});
expect(saved).toHaveLength(1);

These tests are valuable, but they do not prove the PostgreSQL adapter serializes dates correctly or that the Stripe SDK accepts the request. Each adapter needs contract or integration tests against its real boundary. A balanced strategy has three layers:

  1. Domain tests exercise invariants with no ports.
  2. Use-case tests replace outbound ports with focused fakes or stubs.
  3. Adapter tests verify SQL, HTTP, serialization, retries, and vendor behavior.

The port contract should be shared by adapter tests. If every OrderRepository implementation must preserve an order round trip, run the same contract suite against the in-memory and PostgreSQL adapters. This catches a common failure where the fake is convenient but behaves unlike production.

Tip

Treat test doubles as small adapters, not permission to mock every method call. Assert observable behavior and state; brittle interaction scripts merely reproduce the implementation.

Framework Boundaries and Failure Semantics

Framework independence does not mean banning frameworks. Express can parse HTTP, Nest can manage composition, Prisma can issue queries, and a queue library can acknowledge messages. The rule is that framework concepts stop at the adapter.

An inbound HTTP adapter validates syntax, authenticates transport credentials, maps a request to PlaceOrderCommand, invokes the use case, and maps the result to an HTTP response. Whether a declined payment becomes 422 or 409 is an HTTP policy. The application should return a meaningful outcome such as declined, not throw an HttpException.

Outbound adapters perform the reverse translation. They map application values to SQL records or vendor requests and convert external errors into a deliberately small failure vocabulary. That vocabulary deserves design. A timeout may be retryable, a card decline is a business outcome, and invalid credentials are an operational fault. Flattening all three into Error leaves callers unable to respond correctly.

Transaction boundaries also require care. In the example, payment succeeds before save() can fail. No folder structure can make those two systems atomic. The use case needs an explicit policy: retry idempotently with the same order ID, persist an intent before capture, issue compensation, or publish through an outbox. Hexagonal architecture exposes this decision because the boundary is visible; it does not solve distributed transactions automatically.

The same applies to cancellation, timeouts, and observability. Pass cancellation through a port when the use case can act on it. Put metrics and tracing around adapters and use cases, but avoid making domain entities import telemetry SDKs. Record technical context at the edge and business events in domain language.

Failure Modes, Tradeoffs, and When to Skip It

The most common failed implementation is architecture by folders. A team creates domain, application, and infrastructure, then places ORM decorators on domain entities and imports the database client from services. Names changed; dependency direction did not.

Another failure is an interface for every class. Ports should mark meaningful volatility or ownership boundaries, not decorate internal helpers. An OrderTotalCalculator that is deterministic domain logic usually needs no port. Neither does every use case need IPlaceOrderService plus a one-method implementation if a function already expresses the inbound contract.

Leaky ports are equally expensive. Returning ORM entities, framework exceptions, SDK objects, or generic JSON from a port couples both sides while hiding that coupling behind an interface. Overly broad repositories such as Repository<T> also drag persistence operations into use cases that do not need them.

There are real costs: more types, explicit mapping, a composition root, and a learning curve. Debugging crosses more named boundaries. Small changes may touch a command type, port, adapter, and tests. Those costs pay off when business rules are valuable and infrastructure changes independently. They are waste when the application is little more than infrastructure.

Do not reach for a full hexagonal structure for a short-lived migration script, a tiny CRUD admin screen, a proof of concept whose main question is whether an SDK works, or a service with almost no domain policy. Start with a module and pure functions. Extract a port when an external dependency makes tests slow, when two delivery mechanisms need the same use case, or when vendor types begin leaking into decisions.

A pragmatic adoption path is incremental. Choose one painful workflow, write an inbound use-case API around it, identify two or three outbound capabilities, and move translation into adapters. Keep the composition explicit. Measure success by easier changes and clearer tests, not by the number of interfaces or symmetry of the directory tree.

Takeaways

Hexagonal architecture is a dependency discipline: business policy owns the contracts, and technology depends on those contracts. Inbound ports expose use cases to controllers, jobs, and consumers. Outbound ports express what those use cases need from persistence, payments, time, messaging, and other external capabilities. Adapters translate at both edges.

The practical payoff is not theoretical framework freedom. It is the ability to test decisions without booting infrastructure, replace a vendor without rewriting policy, and discuss failure behavior at an explicit boundary. Preserve that payoff by keeping port types domain-oriented, testing real adapters, and acknowledging cross-system consistency problems.

Use the pattern where independent business behavior justifies the extra mapping and composition. Skip or postpone it where the program is simple, temporary, or dominated by one external tool. The best hexagonal architecture is rarely the one with the most sides; it is the one where dependencies point in the right direction and each boundary earns its keep.