Hexagonal Architecture — also known as Ports and Adapters — was introduced by Alistair Cockburn in 2005. Its central idea is deceptively simple: your business logic should not depend on anything external. Databases, HTTP frameworks, message queues — they’re all details that plug into your core, not the other way around.

The Core Idea

Traditional layered architectures place the database at the bottom and the UI at the top, creating a dependency chain that flows downward. Hexagonal Architecture flips this model: the domain sits at the center, and everything else connects through well-defined interfaces.

         ┌──────────────────────┐
         │   HTTP Controller    │  ← Driving Adapter
         └──────────┬───────────┘

              ┌─────▼─────┐
              │   PORT     │  ← Input Port (interface)
              └─────┬──────┘

         ┌──────────▼───────────┐
         │    DOMAIN CORE       │  ← Business Logic
         └──────────┬───────────┘

              ┌─────▼──────┐
              │   PORT      │  ← Output Port (interface)
              └─────┬───────┘

         ┌──────────▼───────────┐
         │  PostgreSQL Adapter  │  ← Driven Adapter
         └──────────────────────┘

There are two types of ports:

  • Input (Driving) Ports — interfaces that the outside world uses to talk to your application (e.g., a use case interface).
  • Output (Driven) Ports — interfaces your application uses to talk to external systems (e.g., a repository interface).

And two types of adapters:

  • Driving Adapters — implementations that call your input ports (REST controllers, CLI commands, GraphQL resolvers).
  • Driven Adapters — implementations of your output ports (database repositories, email services, third-party API clients).

A Practical Example

Let’s build a simple order management system using Hexagonal Architecture in TypeScript.

The Domain

First, define the domain entities — pure business objects with no dependencies:

// domain/Order.ts
export class Order {
  constructor(
    public readonly id: string,
    public readonly customerId: string,
    public readonly items: OrderItem[],
    public status: OrderStatus = 'pending',
  ) {}

  get total(): number {
    return this.items.reduce((sum, item) => sum + item.price * item.quantity, 0);
  }

  confirm(): void {
    if (this.items.length === 0) {
      throw new Error('Cannot confirm an empty order');
    }
    if (this.status !== 'pending') {
      throw new Error(`Cannot confirm order in "${this.status}" status`);
    }
    this.status = 'confirmed';
  }

  cancel(): void {
    if (this.status === 'shipped') {
      throw new Error('Cannot cancel a shipped order');
    }
    this.status = 'cancelled';
  }
}

export interface OrderItem {
  productId: string;
  name: string;
  price: number;
  quantity: number;
}

export type OrderStatus = 'pending' | 'confirmed' | 'shipped' | 'cancelled';

Output Ports

Define the interfaces your domain expects from the outside world:

// ports/output/OrderRepository.ts
export interface OrderRepository {
  save(order: Order): Promise<void>;
  findById(id: string): Promise<Order | null>;
  findByCustomer(customerId: string): Promise<Order[]>;
}

// ports/output/NotificationService.ts
export interface NotificationService {
  sendOrderConfirmation(order: Order): Promise<void>;
  sendOrderCancellation(order: Order): Promise<void>;
}

Input Ports (Use Cases)

Define what the application does — these are your use cases:

// ports/input/ConfirmOrder.ts
export interface ConfirmOrderUseCase {
  execute(orderId: string): Promise<Order>;
}

Application Services

Implement the use cases. These orchestrate domain logic and depend only on ports:

// application/ConfirmOrderService.ts
import { ConfirmOrderUseCase } from '../ports/input/ConfirmOrder';
import { OrderRepository } from '../ports/output/OrderRepository';
import { NotificationService } from '../ports/output/NotificationService';

export class ConfirmOrderService implements ConfirmOrderUseCase {
  constructor(
    private readonly orderRepo: OrderRepository,
    private readonly notifications: NotificationService,
  ) {}

  async execute(orderId: string): Promise<Order> {
    const order = await this.orderRepo.findById(orderId);
    if (!order) {
      throw new Error(`Order ${orderId} not found`);
    }

    order.confirm(); // Domain logic

    await this.orderRepo.save(order);
    await this.notifications.sendOrderConfirmation(order);

    return order;
  }
}

Driven Adapters (Infrastructure)

Now implement the output ports with real infrastructure:

// adapters/output/PostgresOrderRepository.ts
import { Pool } from 'pg';
import { OrderRepository } from '../../ports/output/OrderRepository';
import { Order } from '../../domain/Order';

export class PostgresOrderRepository implements OrderRepository {
  constructor(private readonly pool: Pool) {}

  async save(order: Order): Promise<void> {
    await this.pool.query(
      `INSERT INTO orders (id, customer_id, items, status)
       VALUES ($1, $2, $3, $4)
       ON CONFLICT (id) DO UPDATE SET items = $3, status = $4`,
      [order.id, order.customerId, JSON.stringify(order.items), order.status],
    );
  }

  async findById(id: string): Promise<Order | null> {
    const { rows } = await this.pool.query('SELECT * FROM orders WHERE id = $1', [id]);
    return rows[0] ? this.toDomain(rows[0]) : null;
  }

  async findByCustomer(customerId: string): Promise<Order[]> {
    const { rows } = await this.pool.query(
      'SELECT * FROM orders WHERE customer_id = $1',
      [customerId],
    );
    return rows.map(this.toDomain);
  }

  private toDomain(row: any): Order {
    return new Order(row.id, row.customer_id, row.items, row.status);
  }
}

Driving Adapter (Controller)

// adapters/input/OrderController.ts
import { Router } from 'express';
import { ConfirmOrderUseCase } from '../../ports/input/ConfirmOrder';

export function createOrderRouter(confirmOrder: ConfirmOrderUseCase): Router {
  const router = Router();

  router.post('/:id/confirm', async (req, res) => {
    try {
      const order = await confirmOrder.execute(req.params.id);
      res.json({ success: true, order });
    } catch (error) {
      res.status(400).json({ success: false, message: error.message });
    }
  });

  return router;
}

Wiring It Together

The composition root — the only place that knows about concrete implementations:

// main.ts
import { Pool } from 'pg';
import express from 'express';
import { PostgresOrderRepository } from './adapters/output/PostgresOrderRepository';
import { EmailNotificationService } from './adapters/output/EmailNotificationService';
import { ConfirmOrderService } from './application/ConfirmOrderService';
import { createOrderRouter } from './adapters/input/OrderController';

const pool = new Pool({ connectionString: process.env.DATABASE_URL });
const orderRepo = new PostgresOrderRepository(pool);
const notifications = new EmailNotificationService();
const confirmOrder = new ConfirmOrderService(orderRepo, notifications);

const app = express();
app.use('/orders', createOrderRouter(confirmOrder));
app.listen(3000);

Testing Benefits

The biggest win of Hexagonal Architecture is testability. You can test your domain and use cases without any infrastructure:

// __tests__/ConfirmOrderService.test.ts
describe('ConfirmOrderService', () => {
  it('confirms a pending order', async () => {
    const order = new Order('1', 'cust-1', [{ productId: 'p1', name: 'Widget', price: 10, quantity: 2 }]);

    const repo: OrderRepository = {
      findById: async () => order,
      save: async () => {},
      findByCustomer: async () => [],
    };

    const notifications: NotificationService = {
      sendOrderConfirmation: async () => {},
      sendOrderCancellation: async () => {},
    };

    const service = new ConfirmOrderService(repo, notifications);
    const result = await service.execute('1');

    expect(result.status).toBe('confirmed');
  });
});

No database. No HTTP server. No mocking libraries needed. Just plain interfaces.

When to Use Hexagonal Architecture

Good fit:

  • Applications with complex business rules
  • Projects that need to support multiple interfaces (REST + GraphQL + CLI)
  • Systems where you expect infrastructure to change
  • Teams that practice TDD

Overkill for:

  • Simple CRUD applications
  • Prototypes and MVPs
  • Scripts and one-off tools

Key Takeaways

  1. The domain is the center — everything depends on it, it depends on nothing.
  2. Ports are contracts — interfaces that define how the domain communicates.
  3. Adapters are replaceable — swap Postgres for MongoDB, Express for Fastify, without touching business logic.
  4. Testing becomes trivial — fake adapters let you test in isolation.
  5. The composition root — one place where all the wiring happens.

Hexagonal Architecture requires more upfront structure, but the payoff is a codebase that’s flexible, testable, and resilient to change. When your business logic is the most stable part of your system, you know you’ve done it right.