Skip to content

Hexagonal Architecture in Practice

How to implement hexagonal architecture (ports and adapters) in a real application: clear boundaries, dependency inversion and a testable core domain.

5 min read
Hexagonal architecture diagram showing the core domain surrounded by ports and adapters

Hexagonal architecture, also called ports and adapters, solves one specific problem: keeping your business logic independent of infrastructure. Your domain code should not know whether it is talking to PostgreSQL or DynamoDB, whether requests come from HTTP or a message queue, or whether emails are sent through SendGrid or a test spy. When infrastructure dependencies leak into domain logic, every change to a database or external service requires modifying code that should not care about those details.

The core idea is simple. Your application has an inside (business logic) and an outside (infrastructure). The inside defines ports — interfaces that describe what it needs. The outside provides adapters — implementations that satisfy those interfaces.

The Structure

A hexagonal application has three layers. The core domain contains business rules and entities. Ports are interfaces that define how the domain interacts with the outside world. Adapters implement those ports with real infrastructure.

src/
  domain/           # Pure business logic, no imports from adapters
    entities/
    services/
    ports/          # Interfaces the domain exposes and requires
  adapters/
    driven/         # Infrastructure adapters (DB, email, etc.)
      postgres/
      redis/
      sendgrid/
    driving/        # Entry point adapters (HTTP, CLI, queue consumers)
      http/
      cli/
      queue/
  config/           # Wiring — connects adapters to ports

Defining the Domain and Ports

Start with the domain. Define entities and the ports (interfaces) the domain needs. The domain never imports from adapters — it only knows about its own types and ports.

tstypescript
// domain/entities/order.ts
export interface Order {
  id: string;
  customerId: string;
  items: OrderItem[];
  status: OrderStatus;
  total: number;
  createdAt: Date;
}
 
export interface OrderItem {
  productId: string;
  quantity: number;
  unitPrice: number;
}
 
export type OrderStatus =
  | 'pending'
  | 'confirmed'
  | 'shipped'
  | 'delivered'
  | 'cancelled';
 
// domain/ports/order-repository.ts — driven port (outgoing)
export interface OrderRepository {
  findById(id: string): Promise<Order | null>;
  findByCustomer(customerId: string): Promise<Order[]>;
  save(order: Order): Promise<void>;
  updateStatus(id: string, status: OrderStatus): Promise<void>;
}
 
// domain/ports/payment-gateway.ts — driven port (outgoing)
export interface PaymentGateway {
  charge(customerId: string, amount: number): Promise<PaymentResult>;
  refund(paymentId: string, amount: number): Promise<RefundResult>;
}
 
// domain/ports/notification-service.ts — driven port (outgoing)
export interface NotificationService {
  sendOrderConfirmation(order: Order): Promise<void>;
  sendShippingUpdate(order: Order, trackingId: string): Promise<void>;
}

Implementing Domain Services

Domain services contain the business logic. They depend only on ports — never on concrete implementations. This is where the rules live.

tstypescript
// domain/services/order-service.ts
import type { Order, OrderItem } from '../entities/order';
import type { OrderRepository } from '../ports/order-repository';
import type { PaymentGateway } from '../ports/payment-gateway';
import type { NotificationService } from '../ports/notification-service';
 
export class OrderService {
  constructor(
    private readonly orders: OrderRepository,
    private readonly payments: PaymentGateway,
    private readonly notifications: NotificationService
  ) {}
 
  async createOrder(
    customerId: string,
    items: OrderItem[]
  ): Promise<Order> {
    // Business rule: orders must have at least one item
    if (items.length === 0) {
      throw new Error('Order must contain at least one item');
    }
 
    // Business rule: calculate total from items
    const total = items.reduce(
      (sum, item) => sum + item.quantity * item.unitPrice,
      0
    );
 
    // Business rule: minimum order value
    if (total < 1) {
      throw new Error('Order total must be at least $1.00');
    }
 
    const order: Order = {
      id: crypto.randomUUID(),
      customerId,
      items,
      status: 'pending',
      total,
      createdAt: new Date(),
    };
 
    // Charge the customer
    const payment = await this.payments.charge(customerId, total);
    if (!payment.success) {
      throw new Error(`Payment failed: ${payment.error}`);
    }
 
    // Persist the order
    order.status = 'confirmed';
    await this.orders.save(order);
 
    // Notify the customer (non-critical — don't throw on failure)
    await this.notifications
      .sendOrderConfirmation(order)
      .catch(() => {}); // Log but don't fail the order
 
    return order;
  }
 
  async cancelOrder(orderId: string): Promise<void> {
    const order = await this.orders.findById(orderId);
    if (!order) throw new Error('Order not found');
 
    // Business rule: only pending/confirmed orders can be cancelled
    if (order.status !== 'pending' && order.status !== 'confirmed') {
      throw new Error(
        `Cannot cancel order in '${order.status}' status`
      );
    }
 
    await this.payments.refund(orderId, order.total);
    await this.orders.updateStatus(orderId, 'cancelled');
  }
}

Building Adapters

Adapters implement the port interfaces with real infrastructure. Each adapter lives in its own directory and can be swapped independently.

tstypescript
// adapters/driven/postgres/postgres-order-repository.ts
import type { Order, OrderRepository, OrderStatus } from '../../../domain';
import type { Pool } from 'pg';
 
export class PostgresOrderRepository implements OrderRepository {
  constructor(private readonly pool: Pool) {}
 
  async findById(id: string): Promise<Order | null> {
    const result = await this.pool.query(
      `SELECT o.*, json_agg(oi.*) as items
       FROM orders o
       LEFT JOIN order_items oi ON oi.order_id = o.id
       WHERE o.id = $1
       GROUP BY o.id`,
      [id]
    );
 
    if (result.rows.length === 0) return null;
    return this.toDomain(result.rows[0]);
  }
 
  async findByCustomer(customerId: string): Promise<Order[]> {
    const result = await this.pool.query(
      `SELECT o.*, json_agg(oi.*) as items
       FROM orders o
       LEFT JOIN order_items oi ON oi.order_id = o.id
       WHERE o.customer_id = $1
       GROUP BY o.id
       ORDER BY o.created_at DESC`,
      [customerId]
    );
 
    return result.rows.map((row) => this.toDomain(row));
  }
 
  async save(order: Order): Promise<void> {
    const client = await this.pool.connect();
    try {
      await client.query('BEGIN');
 
      await client.query(
        `INSERT INTO orders (id, customer_id, status, total, created_at)
         VALUES ($1, $2, $3, $4, $5)`,
        [order.id, order.customerId, order.status, order.total, order.createdAt]
      );
 
      for (const item of order.items) {
        await client.query(
          `INSERT INTO order_items (order_id, product_id, quantity, unit_price)
           VALUES ($1, $2, $3, $4)`,
          [order.id, item.productId, item.quantity, item.unitPrice]
        );
      }
 
      await client.query('COMMIT');
    } catch (err) {
      await client.query('ROLLBACK');
      throw err;
    } finally {
      client.release();
    }
  }
 
  async updateStatus(id: string, status: OrderStatus): Promise<void> {
    await this.pool.query(
      'UPDATE orders SET status = $1 WHERE id = $2',
      [status, id]
    );
  }
 
  private toDomain(row: Record<string, unknown>): Order {
    return {
      id: row.id as string,
      customerId: row.customer_id as string,
      items: (row.items as Array<Record<string, unknown>>).map((i) => ({
        productId: i.product_id as string,
        quantity: i.quantity as number,
        unitPrice: i.unit_price as number,
      })),
      status: row.status as OrderStatus,
      total: Number(row.total),
      createdAt: new Date(row.created_at as string),
    };
  }
}

Testing Without Infrastructure

The real payoff of hexagonal architecture is testing. Domain services can be fully tested with in-memory fakes — no database, no external APIs, no Docker containers.

tstypescript
// ❌ Without hexagonal architecture — tests need real infrastructure
describe('OrderService', () => {
  beforeAll(async () => {
    await startPostgresContainer(); // Slow
    await runMigrations();          // Brittle
    await seedTestData();           // Complex setup
  });
  // Tests are slow, flaky, and hard to maintain
});
 
// ✅ With hexagonal architecture — tests use in-memory fakes
describe('OrderService', () => {
  let orderService: OrderService;
  let fakeOrders: InMemoryOrderRepository;
  let fakePayments: FakePaymentGateway;
  let fakeNotifications: FakeNotificationService;
 
  beforeEach(() => {
    fakeOrders = new InMemoryOrderRepository();
    fakePayments = new FakePaymentGateway();
    fakeNotifications = new FakeNotificationService();
 
    orderService = new OrderService(
      fakeOrders,
      fakePayments,
      fakeNotifications
    );
  });
 
  it('creates an order and charges the customer', async () => {
    const items = [{ productId: 'p1', quantity: 2, unitPrice: 25 }];
 
    const order = await orderService.createOrder('customer-1', items);
 
    expect(order.status).toBe('confirmed');
    expect(order.total).toBe(50);
    expect(fakePayments.charges).toHaveLength(1);
    expect(fakePayments.charges[0].amount).toBe(50);
  });
 
  it('rejects order with no items', async () => {
    await expect(
      orderService.createOrder('customer-1', [])
    ).rejects.toThrow('Order must contain at least one item');
  });
 
  it('does not save order if payment fails', async () => {
    fakePayments.shouldFail = true;
 
    await expect(
      orderService.createOrder('customer-1', [
        { productId: 'p1', quantity: 1, unitPrice: 10 },
      ])
    ).rejects.toThrow('Payment failed');
 
    expect(fakeOrders.all()).toHaveLength(0);
  });
});

Wiring It Together

The configuration layer connects adapters to ports. This is the only place that knows about concrete implementations. Dependency injection happens at the application entry point.

tstypescript
// config/container.ts — wiring adapters to ports
import { Pool } from 'pg';
import { OrderService } from '../domain/services/order-service';
import { PostgresOrderRepository } from '../adapters/driven/postgres/postgres-order-repository';
import { StripePaymentGateway } from '../adapters/driven/stripe/stripe-payment-gateway';
import { SendGridNotificationService } from '../adapters/driven/sendgrid/sendgrid-notification-service';
 
export function createContainer(config: AppConfig) {
  const pgPool = new Pool({ connectionString: config.databaseUrl });
 
  const orderRepository = new PostgresOrderRepository(pgPool);
  const paymentGateway = new StripePaymentGateway(config.stripeKey);
  const notifications = new SendGridNotificationService(config.sendgridKey);
 
  const orderService = new OrderService(
    orderRepository,
    paymentGateway,
    notifications
  );
 
  return { orderService, pgPool };
}

Key Takeaways

  1. Domain code imports nothing from adapters — business logic depends only on port interfaces, never on infrastructure
  2. Ports are interfaces, adapters are implementations — swap PostgreSQL for DynamoDB by writing a new adapter, not by changing domain code
  3. Testing is the primary benefit — domain services can be fully tested with in-memory fakes, no containers required
  4. Wiring happens at the entry point — the configuration layer is the only place that knows about concrete adapter classes
  5. Do not over-abstract — if your application has one database and will always have one database, hexagonal architecture adds complexity without benefit; use it when infrastructure flexibility or testability matters
  6. Start with ports for external dependencies — databases, payment gateways, email services, and message queues are the boundaries that benefit most from this pattern
Wilfredo Rujel

Wilfredo Rujel

Full Stack Software Engineer

Share this postX