Architecture

Hexagonal Architecture

Isolates the domain core from all external infrastructure through ports (interfaces defined by the domain) and adapters (implementations at the edges) — ensuring infrastructure depends on the domain, never the other way around.

Intent

Let the domain core be developed, tested and run completely independently of any external technology — database, HTTP framework, message queue, UI. The domain defines the interfaces (ports) it needs; the outside world provides the implementations (adapters).

Hexagonal Architecture was described by Alistair Cockburn in 2005 under the formal name Ports and Adapters. The hexagon has no special geometric meaning — it's just a way of representing that the domain has multiple connection points with the outside world (HTTP, database, UI, tests, queues) without any of them being privileged or baked into the core.

The central principle is Dependency Inversion: high-level modules (domain) don't depend on low-level modules (infrastructure). Both depend on abstractions — the ports.

Problem

In traditional architectures, the domain frequently imports infrastructure directly: entities that extend ORM classes, domain services that call database libraries, business rules that depend on types from an HTTP framework. The result:

  • Compromised testability: testing a business rule requires spinning up a database, an HTTP server, third-party mocks.
  • Coupling to the framework: migrating from one ORM to another, or from REST to GraphQL, requires touching domain code.
  • Difficulty reasoning about the business: domain rules end up hidden among infrastructure code, making them harder to read and evolve.

Hexagonal Architecture solves this directly: the domain defines what it needs (interfaces/ports) without knowing who will implement them. Adapters implement those interfaces and stay completely outside the domain.

Structure

                  ┌─────────────────────────────────────────────────┐
                  │                PRIMARY ADAPTERS                 │
                  │        (drive the domain — driving side)        │
                  │                                                 │
                  │  HTTP Controller   CLI   Unit test              │
                  └────────────────┬────────────────────────────────┘
                                   │ uses inbound port (interface)
                                   ▼
  ┌─────────────────────────────────────────────────────────────────────────┐
  │                               DOMAIN CORE                               │
  │                                                                         │
  │  ┌─────────────────────────────────────────────────────────────────┐    │
  │  │  Inbound ports (use-case/application interfaces)                │    │
  │  │  E.g.: CreateOrderUseCase, FindProductUseCase                   │    │
  │  └─────────────────────────────────────────────────────────────────┘    │
  │                                                                         │
  │            Entities · Value Objects · Domain Services                   │
  │            Business rules · Domain events                               │
  │                                                                         │
  │  ┌─────────────────────────────────────────────────────────────────┐    │
  │  │  Outbound ports (infrastructure interfaces)                     │    │
  │  │  E.g.: OrderRepository, EmailService, PaymentGateway            │    │
  │  └─────────────────────────────────────────────────────────────────┘    │
  └──────────────────────────────────┬──────────────────────────────────────┘
                                     │ implemented by
                                     ▼
                  ┌─────────────────────────────────────────────────┐
                  │               SECONDARY ADAPTERS                │
                  │      (driven by the domain — driven side)       │
                  │                                                 │
                  │  PostgresRepository   StripeAdapter   SESAdapter│
                  └─────────────────────────────────────────────────┘

  Dependency rule: arrows point TOWARD the domain, never outward.
  The domain imports nothing from the adapters.

Primary vs secondary adapters

Primary adapters (or driving adapters) are the ones that start the interaction with the domain: they receive external input (HTTP request, CLI command, queue message, test call) and translate it into calls to the domain's inbound ports. The HTTP Controller is the classic example.

Secondary adapters (or driven adapters) are the ones the domain drives: they implement the outbound ports defined by the domain and bridge to external systems — database, email services, payment gateways, queueing systems. The domain doesn't know what kind of database is being used; it just calls the repository interface.

Ports and adapters — code example

The snippet below shows an outbound port (a repository interface defined by the domain) and a secondary adapter (a concrete implementation with database access). The port lives inside the domain; the adapter lives in the infrastructure.

// ── OUTBOUND port — defined inside the domain ────────────────
// The domain declares what it needs; it doesn't know who implements it.
interface OrderRepository {
  save(order: Order): Promise<void>;
  findById(id: string): Promise<Order | null>;
}

// ── SECONDARY adapter — outside the domain (infrastructure) ──
// Implements the port using the real persistence mechanism.
class PostgresOrderRepository implements OrderRepository {
  constructor(private readonly db: DatabaseConnection) {}

  async save(order: Order): Promise<void> {
    await this.db.query(
      'INSERT INTO orders (id, total, status) VALUES ($1, $2, $3)',
      [order.id, order.total, order.status]
    );
  }

  async findById(id: string): Promise<Order | null> {
    const row = await this.db.queryOne(
      'SELECT * FROM orders WHERE id = $1', [id]
    );
    return row ? Order.reconstitute(row) : null;
  }
}

// ── PRIMARY adapter — HTTP Controller ─────────────────────────
// Translates the HTTP request into a call to the domain use case.
class OrderController {
  constructor(private readonly createOrder: CreateOrderUseCase) {}

  async post(req: Request): Promise<Response> {
    const result = await this.createOrder.execute({
      customerId: req.body.customerId,
      items: req.body.items,
    });
    return Response.json({ orderId: result.id }, { status: 201 });
  }
}

The key point: OrderRepository is an interface that lives inside the domain package. PostgresOrderRepository (or DoctrineOrderRepository) lives in the infrastructure and imports the domain — never the other way around. Swapping the database means swapping the adapter; the domain doesn't change.

When to use

  • Domains with complex business logic: when business rules need to be developed, tested and evolved independently of the technology. Hexagonal Architecture lets you test the whole domain with simple mocks of the ports — no real database, no HTTP server.
  • Multiple primary adapters: when the same domain needs to be driven by HTTP, CLI, queue consumers and tests — each with its own adapter, without changing the domain.
  • High likelihood of swapping infrastructure: projects that anticipate migrating databases, payment gateways or email services benefit greatly from the separation via ports and adapters.
  • Projects with a long lifecycle and a growing team: making boundaries explicit reduces accidental coupling and eases onboarding — the contracts (ports) are living documentation of the integrations.

When to avoid

  • Simple CRUDs without a real domain: creating ports and adapters for an application that only persists and reads data without business rules is over-engineering. The cost of the structure doesn't pay off.
  • Small, short-lived projects: the separation between ports and adapters has an initial organizational cost. For short-lived projects or one-person teams, it can be more productive to start simple and refactor as complexity grows.
  • When the team doesn't understand the pattern: applying Hexagonal without the team understanding the difference between a port and an adapter, or between the domain depending on infrastructure vs. the other way around, tends to result in a poorly named Layered Architecture with "Port" and "Adapter" classes that don't invert anything.

Pros and cons

Pros

  • Domain fully testable with simple mocks of the ports — no database, no HTTP, no external dependencies.
  • Swapping adapters without impacting the domain — swapping the database means swapping only the secondary adapter.
  • Multiple entry points (HTTP, CLI, queue, tests) without modifying the core.
  • Explicit contracts between domain and infrastructure — ports document the integrations.
  • Makes evolving the domain independently of the outside world easier.

Cons

  • Over-engineering for simple applications — the ports-and-adapters structure has configuration and maintenance costs.
  • Proliferation of interfaces: in large systems there can be dozens of ports; without discipline, the count grows unmanageably.
  • Learning curve: the distinction between primary and secondary adapters, and dependency inversion, are non-obvious concepts for developers without prior exposure.
  • Dependency injection becomes mandatory — DI frameworks (NestJS, Spring, Laravel) help, but add configuration complexity.

Common pitfalls

1. Putting the interface (port) outside the domain

The port must be defined by the domain, not by the infrastructure. If the OrderRepository interface lives in the infrastructure package and the domain imports the infrastructure to use it, the inversion was done backwards — the domain went back to depending on the infrastructure. The port lives in the domain; the adapter lives outside and points inward.

2. Over-engineering — ports for everything

It's not necessary to create a port for every interaction. A diagnostic logger, a UUID generator, a system clock — sometimes it's acceptable to inject them directly without an elaborate interface. A port has value when the concrete implementation needs to be swappable (database, gateway, email) or when test isolation is critical. For stable utilities with no impact on business behavior, the abstraction can be unnecessary.

Rule of thumb: create a port when you need more than one implementation (production vs. test, or provider A vs. provider B), or when the concrete dependency would make unit testing the domain impossible. If the answer is "I'll never need to swap this," don't create the port.

3. Confusing Hexagonal with "just more layers"

The difference between Layered Architecture and Hexagonal isn't cosmetic. In classic Layered Architecture, the Infrastructure sits "below" the Domain and the Domain can reference infrastructure types indirectly. In Hexagonal, the direction is explicitly inverted: the infrastructure implements domain interfaces and therefore points to the domain. If the adapters in your project don't implement interfaces defined inside the domain package, you have Layered Architecture with different names.

4. Not testing the domain in isolation

The main benefit of Hexagonal Architecture is testing the domain without real infrastructure. If domain tests still need a real database, an HTTP server or framework mocks, the boundary between domain and infrastructure was violated somewhere. Domain tests should use only mocks or stubs of the ports — simple in-memory implementations that satisfy the interface.

Related architectures and patterns

In MVC, the Controller that receives HTTP requests acts exactly like a primary adapter (driving adapter): it translates external input into calls to the domain without the domain needing to know about the HTTP layer. Hexagonal Architecture formalizes and generalizes that role — any entry point (CLI, queue, test) is a primary adapter, not just the Controller.

Layered Architecture shares the goal of isolating the domain, but with a fundamental difference in direction: in layers, infrastructure sits "below" and can be referenced by upper layers via interfaces. In Hexagonal, infrastructure sits "outside" and implements interfaces the domain defines — the dependency is explicitly inverted. In practice, the two approaches converge when Layered Architecture uses repositories with dependency inversion.

The Adapter (GoF) pattern is the implementation mechanism of each hexagonal adapter: a secondary adapter is an Adapter that implements the port (the domain's target interface) and wraps the external technology (the adaptee). The difference is scale and context: the GoF Adapter is a class-level pattern; the hexagonal adapter is an architectural concept that frequently uses the Adapter pattern internally.

Strategy (GoF) is structurally equivalent to the port/adapter relationship: one interface and multiple interchangeable implementations. Hexagonal Architecture applies that same mechanism at an architectural scale — each port is an infrastructure Strategy, and the domain injects the correct implementation via dependency injection.