Hexagonal Architecture (Ports and Adapters)

Learning Objectives

Structure an application core with ports and adapters.

Distinguish driving (primary) and driven (secondary) adapters.

Test the core without infrastructure.

Prerequisites: Modules 21, 22, 71.

Intuition and Definition

Alistair Cockburn's hexagonal architecture: the application core (domain + use cases) is surrounded by ports — interfaces it owns — and adapters that connect ports to the outside world.

Driving (primary) ports/adapters: how the world uses the application — HTTP controllers, CLI, test harnesses call *into* the core.

Driven (secondary) ports/adapters: what the application needs — databases, message brokers, email, hardware — implemented *outside* the core.

All source dependencies point inward; the core never imports an adapter.

Why It Matters

You can run the whole core with in-memory adapters in tests, swap PostgreSQL for DynamoDB or SMS for push notifications by writing a new adapter, and keep frameworks out of business logic.

Syntax and Language Notes

Language-neutral; in C++ ports are abstract classes, in TypeScript interfaces, in Python protocols/ABCs.

Complete Example

#include <iostream>
#include <map>
#include <optional>
#include <string>
#include <vector>

// ===================== core =====================
struct VehicleRecord { std::string vin; int km; };

// driven ports (owned by the core)
struct VehicleStore { virtual ~VehicleStore() = default; virtual std::optional<VehicleRecord> load(const std::string&) = 0; virtual void save(const VehicleRecord&) = 0; };
struct Notifications { virtual ~Notifications() = default; virtual void serviceDue(const std::string& vin, int km) = 0; };

// driving port (the use case API)
class OdometerUseCases {
    VehicleStore& store_; Notifications& notify_;
public:
    OdometerUseCases(VehicleStore& s, Notifications& n) : store_(s), notify_(n) {}
    std::string report(const std::string& vin, int km) {
        auto v = store_.load(vin);
        if (!v) return "unknown vehicle";
        if (km < v->km) return "rejected: odometer cannot go backwards";
        bool crossed = v->km / 15000 != km / 15000;           // crossed a 15,000 km service boundary
        store_.save({vin, km});
        if (crossed) notify_.serviceDue(vin, km);
        return "recorded";
    }
};

// ===================== adapters =========
…
> V1 15020
    [sms] V1 reached 15020 km: book a service
recorded
> V1 14000
  rejected: odometer cannot go backwards
> V9 100
  unknown vehicle
test adapter recorded service due for: T1

Step-by-Step Walkthrough

The core defines what it needs (VehicleStore, Notifications) and what it offers (OdometerUseCases).

The CLI adapter translates text into use-case calls; an HTTP adapter would do the same with JSON.

Console and recording adapters implement the same driven port — production vs test wiring with no core changes.

Business rules (no rollback, service every 15,000 km) live in the core only.

flowchart LR
  CLI[CLI adapter] --> UC[OdometerUseCases<br/>core]
  HTTP[HTTP adapter] --> UC
  UC --> VS[VehicleStore port]
  UC --> NP[Notifications port]
  Mem[InMemoryStore] -. implements .-> VS
  Sms[ConsoleNotifications] -. implements .-> NP

Common Mistakes

Ports shaped like one technology (saveToPostgres(...)) — define them in the core's language (save(vehicle)).

Adapters containing business logic. Core types depending on framework annotations.

One port per table instead of per need of the use cases.

Trade-offs and Alternatives

More interfaces and mapping than a framework-centric design; pays off for long-lived systems with real business rules and several delivery channels. Clean architecture (Module 73) and onion architecture are close relatives with different vocabularies.

Real-World Use

Microservices with HTTP and message-queue entry points, fleet/telematics back ends with several data sources, embedded applications with simulator and hardware adapters, and any system that needs fast, infrastructure-free tests of business rules.

Practice

Q1. (Easy) Who owns the port interfaces?

Answer: The application core.

Q2. (Medium) Driving vs driven adapter?

Answer: Driving adapters call into the core (UI, API, CLI, tests); driven adapters are called by the core through its ports (database, email, hardware).

Q3. (Advanced) How would you add a message-queue entry point that records odometer readings?

Answer: Write a new driving adapter that consumes messages, parses them, and calls OdometerUseCases::report; the core and other adapters don't change. Make the handler idempotent (Module 69) since queues may redeliver.

Interview Question

Q1. "Explain hexagonal architecture in two minutes." Answer: Put the business core in the middle; it exposes driving ports (use-case APIs) and declares driven ports (interfaces for what it needs, like storage or notifications). Everything technical — web controllers, databases, queues, SMS providers — is an adapter outside the core, plugged into a port. Dependencies point inward, so the core can be tested with in-memory adapters, technologies can be swapped by writing adapters, and frameworks don't leak into business rules.

Quick Quiz

Q1. Who coined hexagonal architecture?

Answer: Alistair Cockburn.

Q2. Can the core import an adapter?

Answer: No — dependencies point inward only.

Q3. Which adapter would a unit test use for storage?

Answer: An in-memory driven adapter (fake).

Summary

Core + ports (owned by the core) + adapters (outside); dependencies point inward.

Swap technologies and test the core without infrastructure.

Related: Modules 19, 22, 71, 73.