Siddhant Deval
Siddhant Deval
backend5 min read

Ports & Adapters: The Hexagonal Architecture Pattern in Node.js

Hexagonal Architecture (Ports & Adapters) makes a Node.js application completely agnostic of its delivery mechanism and persistence technology. By defining Ports as interfaces in the domain and implementing Adapters in infrastructure, the same billing engine can be driven by an HTTP controller, a Kafka consumer, or a CLI script without changing a single domain class.

Ports & Adapters: The Hexagonal Architecture Pattern in Node.js

Domain objects are autonomous state machines with enforced invariants — they are not passive bags of data passed between controller functions. By Part 6, we have a billing engine where the domain model is genuinely isolated from infrastructure — all communication flows through IOrderRepository, IPaymentProcessor, and IEventBus. What we have not yet named, precisely, is the architectural pattern that makes this possible. That pattern is called Ports and Adapters — or Hexagonal Architecture — and naming it precisely gives us a vocabulary that makes the design decisions in Parts 2–6 reproducible, teachable, and verifiable.

This article makes the pattern concrete: defines Ports and Adapters precisely, shows how three different delivery mechanisms (HTTP, Kafka, CLI) drive the same application core without modification, demonstrates swapping infrastructure implementations (Stripe → PayPal, Prisma → TypeORM), and shows how to verify the hexagonal constraint at the TypeScript level.

Architectural Note

Prerequisites: Parts 2–6 — this article names and systematizes the structure already built. No new code is introduced from scratch; every example extends existing classes from previous parts.


1. What Hexagonal Architecture Actually Is

Alistair Cockburn coined the term in 2005 with one motivating insight: applications should work equally well when driven by automated test suites, human UIs, batch programs, or message queues — without requiring different application code for each.

The metaphor is a hexagon (the shape is arbitrary — what matters is that it implies no "top" or "bottom"):

  • Inside the hexagon: the application core — Domain layer + Application layer. It contains all business logic. It knows nothing about HTTP, Kafka, Prisma, or any delivery mechanism.
  • Outside the hexagon: driving adapters (who trigger the application) and driven adapters (who the application triggers).
  • The hexagon's boundary: ports — interfaces defined inside the hexagon that describe what the outside world must provide.

The hexagonal shape has no top or bottom because HTTP is not "above" the application, and Prisma is not "below" it. They are both outside — symmetrically. This reframing eliminates the conceptual bias that makes engineers think infrastructure is somehow foundational to business logic.

1.1 The Two Sides: Driving and Driven

Side Who Initiates Examples Called
Driving (Left) External actor triggers the application HTTP controller, Kafka consumer, CLI command Driving Adapters
Driven (Right) Application triggers the external system Database, payment gateway, email service, event broker Driven Adapters

Both sides communicate with the application core exclusively through Ports — interfaces.


2. Ports: Interfaces at the Hexagon Boundary

2.1 Driving Ports (Input Ports)

A Driving Port is the interface that the application core exposes to the driving side. In our architecture, Use Cases are the driving ports:

TYPESCRIPT
// src/application/order/ports/IPlaceOrderPort.ts
// (Often just the Use Case class itself serves as the port — this is explicit naming)
import { PlaceOrderCommand } from '../commands/PlaceOrderCommand';
import { OrderId } from '../../../domain/order/OrderId';

export interface IPlaceOrderPort {
  execute(command: PlaceOrderCommand): Promise<OrderId>;
}

Alternatively — and more commonly — the Use Case class itself is the Port. Driving adapters depend directly on the Use Case class. Either approach is valid; what matters is that the adapter imports the Use Case, not the other way around.

2.2 Driven Ports (Output Ports)

Driven Ports are defined in the Domain or Application layer. They describe what external systems the application needs, using domain vocabulary only:

TYPESCRIPT
// src/domain/order/ports/IOrderRepository.ts — already defined in Part 4
export interface IOrderRepository {
  findById(id: OrderId): Promise<Order | null>;
  findByCustomerId(customerId: CustomerId): Promise<Order[]>;
  save(order: Order): Promise<void>;
  nextId(): OrderId;
}

// src/domain/payment/ports/IPaymentProcessor.ts
export interface IPaymentProcessor {
  charge(amount: Money, orderId: OrderId): Promise<PaymentReceipt>;
  refund(receiptId: ReceiptId, amount: Money): Promise<RefundConfirmation>;
}

// src/domain/shared/ports/IEmailPort.ts
export interface IEmailPort {
  send(options: {
    to: string;
    subject: string;
    template: string;
    variables: Record<string, string | number>;
  }): Promise<void>;
}

The word "Prisma" does not appear. The word "Stripe" does not appear. The word "SendGrid" does not appear. These are pure domain-language interfaces — they describe capabilities the application needs, not implementations it depends on.


3. Adapters: Connecting the Outside World to the Ports

3.1 Driven Adapters (Right Side)

Driven Adapters implement Driven Ports. They live in src/infrastructure/ and import both the Domain interfaces they implement AND the external library they adapt:

TYPESCRIPT
// src/infrastructure/payment/StripePaymentProcessor.ts
import Stripe from 'stripe';                               // External library
import { IPaymentProcessor } from '../../domain/payment/ports/IPaymentProcessor';
import { Money } from '../../domain/payment/Money';
import { OrderId } from '../../domain/order/OrderId';
import { PaymentReceipt } from '../../domain/payment/PaymentReceipt';
import { DomainError } from '../../domain/shared/DomainError';

export class StripePaymentProcessor implements IPaymentProcessor {
  constructor(private readonly stripe: Stripe) {}

  async charge(amount: Money, orderId: OrderId): Promise<PaymentReceipt> {
    try {
      const intent = await this.stripe.paymentIntents.create({
        amount: amount.amountCents,
        currency: amount.currency.toLowerCase(),
        metadata: { orderId },
        confirm: true,
        payment_method: 'pm_card_visa', // In production: supplied by client
      });

      if (intent.status !== 'succeeded') {
        throw new DomainError(`Payment failed with status: ${intent.status}`);
      }

      return PaymentReceipt.create({
        receiptId: intent.id as any,
        orderId,
        amount,
        providerReference: intent.id,
        capturedAt: new Date(),
      });
    } catch (err) {
      if (err instanceof Stripe.errors.StripeCardError) {
        throw new DomainError(`Card declined: ${err.message}`);
      }
      throw err; // Re-throw unexpected Stripe errors
    }
  }

  async refund(receiptId: ReceiptId, amount: Money): Promise<RefundConfirmation> {
    const refund = await this.stripe.refunds.create({
      payment_intent: receiptId,
      amount: amount.amountCents,
    });
    return RefundConfirmation.create({ refundId: refund.id as any, amount, processedAt: new Date() });
  }
}
TYPESCRIPT
// src/infrastructure/payment/PayPalPaymentProcessor.ts — a second driven adapter
import { PayPalClient } from '@paypal/checkout-server-sdk';
import { IPaymentProcessor } from '../../domain/payment/ports/IPaymentProcessor';
// ... same interface, different implementation

Swapping from Stripe to PayPal in production requires changing one line in the DI container: bind(IPaymentProcessor).to(PayPalPaymentProcessor) instead of StripePaymentProcessor. The application core — PlaceOrderUseCase, Order, Money — changes nothing.

3.2 Driving Adapters (Left Side)

Driving Adapters receive external input and translate it into a Command DTO that the Use Case understands:

HTTP Adapter:

TYPESCRIPT
// src/interface/http/OrderController.ts
import { Request, Response } from 'express';
import { PlaceOrderUseCase } from '../../application/order/PlaceOrderUseCase';
import { PlaceOrderCommand } from '../../application/order/commands/PlaceOrderCommand';
import { Money } from '../../domain/payment/Money';

export class OrderController {
  constructor(private readonly placeOrder: PlaceOrderUseCase) {}

  async create(req: Request, res: Response): Promise<void> {
    const command: PlaceOrderCommand = {
      customerId: req.body.customerId,
      items: req.body.items.map((i: any) => ({
        productId: i.productId,
        quantity: i.quantity,
        unitPrice: Money.of(i.currency, i.unitPrice),
      })),
      shippingAddress: req.body.shippingAddress,
    };

    try {
      const orderId = await this.placeOrder.execute(command);
      res.status(201).json({ orderId });
    } catch (error) {
      this.handleError(error, res);
    }
  }
}

Kafka Adapter:

TYPESCRIPT
// src/interface/kafka/OrderCommandConsumer.ts
import { Consumer, EachMessagePayload } from 'kafkajs';
import { PlaceOrderUseCase } from '../../application/order/PlaceOrderUseCase';
import { PlaceOrderCommand } from '../../application/order/commands/PlaceOrderCommand';
import { Money } from '../../domain/payment/Money';

export class OrderCommandConsumer {
  constructor(
    private readonly consumer: Consumer,
    private readonly placeOrder: PlaceOrderUseCase,
  ) {}

  async start(): Promise<void> {
    await this.consumer.subscribe({ topic: 'order.commands', fromBeginning: false });
    await this.consumer.run({
      eachMessage: async ({ message }: EachMessagePayload) => {
        const payload = JSON.parse(message.value!.toString());
        const command: PlaceOrderCommand = {
          customerId: payload.customerId,
          items: payload.items.map((i: any) => ({
            productId: i.productId,
            quantity: i.quantity,
            unitPrice: Money.of(i.currency, i.unitPrice),
          })),
          shippingAddress: payload.shippingAddress,
        };
        // Same use case — same PlaceOrderCommand — zero application code changes
        await this.placeOrder.execute(command);
      },
    });
  }
}

CLI Adapter:

TYPESCRIPT
// src/interface/cli/PlaceOrderCommand.ts
import { program } from 'commander';
import { PlaceOrderUseCase } from '../../application/order/PlaceOrderUseCase';
import { Money } from '../../domain/payment/Money';

export function registerPlaceOrderCommand(placeOrder: PlaceOrderUseCase): void {
  program
    .command('place-order')
    .requiredOption('--customer <id>')
    .requiredOption('--product <id>')
    .requiredOption('--quantity <n>', '', parseInt)
    .requiredOption('--price <amount>', '', parseFloat)
    .action(async (opts) => {
      const command = {
        customerId: opts.customer,
        items: [{ productId: opts.product, quantity: opts.quantity, unitPrice: Money.of('USD', opts.price) }],
        shippingAddress: { street: '1 CLI St', city: 'Test', state: 'TS', postalCode: '00000', countryCode: 'US' },
      };
      const orderId = await placeOrder.execute(command);
      console.log(`Order placed: ${orderId}`);
    });
}

All three adapters call the same PlaceOrderUseCase.execute() with a PlaceOrderCommand. The use case implementation is identical in all three contexts. If the business adds a GraphQL API or a gRPC endpoint, it is a new adapter — not a new use case.


4. The Symmetric Architecture

The full hexagonal structure for the billing engine:

The hexagon has no privileged entry point. HTTP is not more important than Kafka. PostgreSQL is not more fundamental than in-memory storage. They are all outside the boundary, all connected through ports.


5. Verifying the Hexagonal Constraint

The hexagonal constraint — that the application core imports nothing from adapters — can be verified mechanically:

5.1 TypeScript Project References (Already Set Up in Part 2)

The src/domain/tsconfig.json has no references → the Domain layer cannot import from anywhere. The src/application/tsconfig.json references only src/domain → the Application layer cannot import from Infrastructure or Interface.

Any violation produces a compiler error before a single test runs.

5.2 Dependency Audit with ts-prune or eslint-plugin-import

BASH
# Check for any domain files importing from infrastructure
grep -r "from.*infrastructure" src/domain/
# Must return: (empty)

grep -r "from.*infrastructure" src/application/
# Must return: (empty)

grep -r "from.*prisma" src/domain/
# Must return: (empty)

grep -r "from.*stripe" src/application/
# Must return: (empty)

These four shell commands are an effective CI gate. Add them to .github/workflows/lint.yml to catch boundary violations on every pull request.

5.3 The Dependency Direction Test

For any file F in src/domain/ or src/application/:

  1. Open F
  2. List every import statement
  3. Verify every import points to src/domain/ or src/application/ (inner circles only)
  4. Any import pointing to src/infrastructure/, src/interface/, or an external npm package in Infrastructure is a violation

The automated version of this test is a custom ESLint rule using eslint-import-resolver-typescript with no-restricted-imports configured per directory.


6. The Test Pyramid Through the Hexagonal Lens

Hexagonal Architecture makes the testing strategy self-evident:

Test Type What It Tests Adapter Used Speed
Domain Unit Order, Money, LineItem methods None (pure domain) < 1ms each
Application Unit Use Cases with in-memory doubles InMemoryOrderRepository, MockPaymentProcessor < 5ms each
Adapter Integration PrismaOrderRepository contract Real Postgres (Docker) ~100ms each
Driving Adapter E2E HTTP controller → Use Case → In-Memory repo supertest + In-Memory adapters < 20ms each
Full Integration All real adapters assembled Postgres + Stripe sandbox ~500ms each

The majority of tests live in the first two tiers — fast, deterministic, no external dependencies. The hexagonal structure is the architectural reason this is possible: the application core is independent of all adapters, so it can be tested with the fastest possible doubles.


Summary

Concept Domain Rule
Port An interface defined inside the hexagon; describes a required capability in domain language
Driving Adapter Translates external input (HTTP, Kafka, CLI) into a Command DTO; calls the Use Case
Driven Adapter Implements a Driven Port using a specific technology; lives in Infrastructure
Symmetry HTTP and Kafka are equally "outside" the hexagon; neither is privileged
Constraint Verification TypeScript project references + grep audit + CI gate enforce the dependency direction
Test Pyramid Domain units are fastest; adapter integration tests are fewest; the hexagonal structure makes this natural

What's Next

In Part 8, we revisit all five SOLID principles through a backend-first lens — showing how each principle manifests specifically in Express.js middleware, repository adapters, and event handler registration, with before/after refactoring examples for each. Part 8: SOLID in Node.js & Express →

Research & Synthesis Note

This article was developed with AI-assisted deep search, specification cross-referencing, and technical research synthesis.

#Hexagonal Architecture#Ports & Adapters#Node.js#TypeScript#Clean Architecture#OOP#Backend Architecture
Siddhant Deval

Written by Siddhant Deval

Senior Full-Stack Engineer building high-scale architectures, browser performance engineering systems, and SaaS platforms.