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: T1Step-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 .-> NPCommon 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.