Hexagonal Architecture: Ports, Adapters, and Testable Cores

Learn hexagonal architecture through ports, adapters, and inward dependencies, with practical code, trade-offs, failure modes, and security guidance.

published: reading time: 10 min read author: GeekWorkBench
Quick Summary

Hexagonal architecture keeps application policy behind ports that describe its needs, while adapters translate to HTTP, databases, and other external systems. The guide follows customer registration through the dependency direction, TypeScript contracts, test choices, and production failure cases. Use ports where they protect a meaningful boundary, then test adapters against the real infrastructure behavior their contracts promise.

Hexagonal Architecture: Ports, Adapters, and Testable Cores

Introduction

If a registration use case imports a PostgreSQL client, its business flow depends directly on the database library:

import { PostgresClient } from "./postgres";

class RegisterCustomer {
  constructor(private readonly database: PostgresClient) {}
}

Instead, let the core define a CustomerStore port and have the PostgreSQL adapter implement it. The use case then depends on a contract it owns, so tests or another entry point can supply a different adapter without pulling database code into the core. This guide explains where those ports belong, how adapters translate at the boundary, and which tests still need real infrastructure.

When to Use / When Not to Use

Use ports and adapters when the same use case needs more than one entry point, when external systems are costly to run in tests, or when infrastructure changes should not rewrite business policy. It is especially useful around integrations that fail, change contracts, or need careful substitution in tests.

Skip a formal port for a stable library call that is not a meaningful boundary. A trivial application can be harder to read when every function gets wrapped in an interface. Hexagonal architecture also does not mean “the database is always easy to replace”; data models and operational behavior still matter.

Core concepts

A port is a contract expressed in terms the application understands. A driving (primary) port exposes a capability such as registerCustomer. A driven (secondary) port describes something the application needs, such as saving a customer or sending a receipt. An adapter translates between that contract and a technology: a REST controller adapts HTTP requests to a driving port, while a PostgreSQL adapter implements a driven port.

Dependencies point toward the core. The core owns the abstractions it needs; adapters depend on those abstractions. This differs from drawing concentric policy rings: hexagonal architecture emphasizes the connection points and the ability to attach or replace actors at the boundary. Tests can use in-memory adapters, but those do not replace integration tests against the real database or provider.

For registration, RegisterCustomerService imports the CustomerStore interface and calls save; it never imports a PostgreSQL package. PostgresCustomerStore imports that interface and implements it, so the compile-time dependency points from the adapter to the core. At startup, a composition root constructs the service with a PostgreSQL store in production or a memory store in a test. At runtime the service calls the supplied object, but that call direction does not change the source dependency direction.

Mermaid diagram

flowchart LR
  CLIENT[Web client] --> HTTP[HTTP adapter]
  HTTP --> IN[Register customer port]
  IN --> CORE[Application core]
  CORE --> OUT[Customer store port]
  PG[PostgreSQL adapter] --> OUT
  CORE --> MAIL[Receipt port]
  SMTP[Email adapter] --> MAIL

The arrow direction at the driven ports indicates that adapters implement contracts owned by the core. That detail matters: the core should not import an SMTP client just because one adapter uses it.

Implementation / code example

Here is the shape of a use case in TypeScript. Adapters are omitted except for the contract boundary:

export interface RegisterCustomer {
  execute(input: { email: string }): Promise<{ id: string }>;
}

export interface CustomerStore {
  save(customer: { id: string; email: string }): Promise<void>;
}

export interface IdGenerator {
  next(): string;
}

export class RegisterCustomerService implements RegisterCustomer {
  constructor(
    private readonly store: CustomerStore,
    private readonly ids: IdGenerator,
  ) {}

  async execute(input: { email: string }): Promise<{ id: string }> {
    const email = input.email.trim().toLowerCase();
    if (!email.includes("@")) throw new Error("Invalid email");
    const id = this.ids.next();
    await this.store.save({ id, email });
    return { id };
  }
}

The HTTP adapter parses and validates the request shape, calls the driving port, and translates errors into protocol responses. A PostgreSQL adapter maps the customer record to tables and implements CustomerStore. A focused unit test can supply a memory store and deterministic ID generator. The email check here is deliberately small; production registration needs ownership verification and abuse controls.

Trade-off table

Choice Benefit Cost
Core-owned ports Policy does not import vendor APIs Contracts need careful naming and scope
Replaceable adapters Fast isolated tests and integration swaps Fake adapters can hide real integration faults
Multiple driving adapters Reuse one capability across HTTP, jobs, or CLI Entry points still need consistent authorization
Explicit composition root Dependencies are visible at startup Wiring adds code and configuration work
Port per meaningful boundary Makes policy’s external needs explicit Too many tiny ports make navigation harder
Shared database transaction port Can keep a use case’s writes atomic Couples the use case to transaction semantics

Production failure scenarios + mitigations

A fake store passes tests but production SQL violates a constraint. A memory fake may accept two customers with the same email even though the production database has a unique index. The service test passes, then the adapter throws a constraint error in production. Run adapter integration tests against the actual database engine, including migrations, uniqueness behavior, and the mapping from database errors to application outcomes.

A provider times out after accepting a payment or message. The adapter cannot infer whether the remote action happened. Blindly retrying can charge twice or send duplicate receipts. Use provider idempotency keys, durable operation identifiers, bounded retries, and reconciliation for ambiguous outcomes; let the application policy decide whether an unresolved operation should wait, fail, or enter a review queue.

The adapter swallows an error and reports success. For example, a mail adapter might catch a provider rejection and return normally, leaving the registration use case to report success even though no verification email was sent. Preserve error meaning across the boundary. Map expected domain outcomes explicitly and emit metrics for timeout, rejection, and dependency failure separately.

Observability checklist

  • Include trace context as requests cross adapters and ports.
  • Measure dependency latency, timeout, retry, and rejection rates by adapter.
  • Log the use-case outcome with a stable operation identifier.
  • Track queue lag for asynchronous driving adapters and dead-letter volume for failed messages.
  • Avoid high-cardinality labels such as raw customer IDs in metrics.

Security/compliance notes

Authenticate at the inbound adapter, then authorize the requested operation in a policy path every adapter shares. An internal queue consumer is still an entry point. Outbound adapters should constrain scopes, validate TLS, protect secrets, and redact provider payloads from logs. If a port exposes personal data, minimize fields and define retention at the owning use case rather than relying on UI filtering.

Common pitfalls / anti-patterns

  • Treating every class as a port, creating interfaces with one trivial implementation and no boundary value.
  • Leaking vendor request/response types into core contracts.
  • Believing an in-memory fake proves transaction isolation or provider behavior.
  • Putting business policy in an adapter because it is the “outer” layer.
  • Making adapters interchangeable in theory while their semantics differ, such as one store silently allowing duplicate emails.

Quick Recap Checklist

  • Does each port describe an application capability or need?
  • Are ports owned and named from the core’s perspective?
  • Do adapters translate protocols without owning business policy?
  • Does the dependency graph point from adapters to core-owned contracts, with wiring kept at startup?
  • Are real infrastructure behaviors covered by integration tests?
  • Do retries account for operations whose remote outcome is unknown?
  • Can every inbound path apply the same authentication and authorization rules, including jobs and message consumers?

Interview Questions

1. Why is it called hexagonal architecture?

The shape represents a core with several possible connections, not six required sides. Ports and adapters are the important concepts.

2. Who should define a port?

The application core defines a contract in terms of its needs. An infrastructure adapter implements it, so vendor-specific details stay outside the core.

3. Are mocks enough to test an adapter boundary?

No. Mocks help test policy, but integration tests must exercise important real behavior such as SQL constraints, serialization, credentials, and remote error mapping.

4. In the registration example, which way do source dependencies point?

RegisterCustomerService imports the core-owned CustomerStore contract. PostgresCustomerStore imports and implements that contract, so the adapter depends on the core even though the service calls the adapter at runtime.

5. What is a driving port?

It is an application capability called from outside the core, such as registering a customer. An HTTP controller or message consumer can adapt its input to that port.

6. What is a driven port?

It is a contract for something the application needs from outside, such as saving a customer or sending a receipt. A database or email adapter implements that contract.

7. Where should dependency wiring live?

In a composition root at the application boundary, usually during startup. It chooses concrete adapters and passes them into the use case without making the use case construct infrastructure itself.

8. Should every external function call get its own port?

No. Create a port when it protects a meaningful policy boundary, isolates a volatile or costly dependency, or enables a useful alternate adapter. Wrapping stable, trivial calls adds indirection without much benefit.

9. Can two adapters for the same port behave differently?

They can, but surprising semantic differences are a contract problem. If one customer store enforces unique email addresses and another silently accepts duplicates, tests with the second adapter may not represent production behavior.

10. How should an adapter handle a timeout after sending a request?

It should preserve the fact that the result is ambiguous instead of treating the request as definitely failed. The application can then use an idempotency key, query operation status, retry safely, or route the operation for reconciliation.

Further Reading

Conclusion

Ports and adapters keep policy expressed in application terms while the outer layer handles protocols and vendor details. The useful test is whether a port protects a real boundary; integration tests then confirm that each adapter honors its contract against the system it represents.

Category

Related Posts

Clean and Onion Architecture: Keeping Policy at the Center

Compare clean and onion architecture, learn how concentric boundaries keep policy independent, and apply dependency rules with practical code and trade-offs.

#software architecture #clean architecture #onion architecture

Layered Architecture: A Guide to Responsibility Tiers

Learn layered architecture's responsibility tiers, dependency rules, code examples, and trade-offs for separating presentation, domain, and infrastructure.

#software architecture #layered architecture #maintainability

Modular Monoliths: Strong Boundaries in One Deployment

Design a modular monolith with module ownership, public contracts, and enforced dependency rules while keeping one shared deployment and runtime.

#software architecture #modular monolith #modules