Hexagonal (ports and adapters)
Hexagonal architecture turns the layered picture inside out: business rules sit in the middle and everything technical, the web and the database included, plugs in from outside through ports the core defines. This page builds Pedal's core once, drives it from a phone and a nightly job, and swaps its payment adapter for a test fake.
1. In one picture
Alistair Cockburn's hexagonal architecture (also called ports and adapters) puts the business rules in the middle and everything else outside. The core declares ports: interfaces in its own language, both for what it offers ("unlock a bike") and for what it needs ("release a bike from a dock", "charge a card"). Adapters connect real technology to those ports: a web controller, a database repository, the dock hardware's protocol, a payment SDK, or a fake for tests.
- Driving (inbound) adapters call into the core: an HTTP API, a command-line tool, a scheduled job, a test.
- Driven (outbound) adapters are called by the core through its ports: storage, hardware, payments, email.
- The hexagon shape means nothing; it just leaves room to draw many ports. What matters is that every dependency points into the core.
2. Pedal built this way
The same core is driven by the rider's phone over HTTP and by a nightly job, and drives dock hardware, storage and payments. Follow an unlock.
Compared with layers, the database is no longer "at the bottom": it's one adapter among many, on the outside, just like the web. That's the shift: the rules don't sit on top of storage; storage plugs into the rules.
3. The rules
| Rule | Why it exists | Broken when… |
|---|---|---|
| The core imports nothing from outside | Business rules outlive frameworks, databases and vendors. | A domain class imports the ORM base class or the HTTP request type. |
| Ports are written in the core's language | Swapping a vendor never changes the core's interface. | The payment port has methods named after the provider's SDK. |
| Adapters translate, nothing more | Rules stay in one place. | The web adapter decides who gets a member discount. |
| Every adapter can be replaced by a fake | The core is tested quickly, with no network or database. | A test of the pricing rule needs a running database. |
4. How it goes wrong
5. The code
Pedal's RentBikes core with three outbound ports (dock locks, ride storage, payments) and two driving adapters (an HTTP handler and a nightly job that closes abandoned rides). The last line runs the same core with a payment adapter that always declines.
from __future__ import annotations
from abc import ABC, abstractmethod
# ================= The hexagon: Pedal's core. It imports nothing from outside. =================
# Outbound ports: what the core needs from the world, in the core's words.
class BikeLocks(ABC):
@abstractmethod
def release(self, dock: str) -> str: ...
class RideStore(ABC):
@abstractmethod
def save(self, ride_id: str, ride: dict) -> None: ...
@abstractmethod
def load(self, ride_id: str) -> dict: ...
@abstractmethod
def open_rides(self) -> list[str]: ...
class Payments(ABC):
@abstractmethod
def charge(self, rider: str, pence: int) -> bool: ...
# Inbound port: what the world can ask the core to do.
class RentBikes:
def __init__(self, locks: BikeLocks, store: RideStore, payments: Payments) -> None:
self.locks, self.store, self.payments = locks, store, payments
self.count = 0
def unlock(self, rider: str, plan: str, dock: str) -> str:
bike = self.locks.release(dock)
self.count += 1
ride_id = f"R{self.count}"
self.store.save(ride_id, {"rider": rider, "plan": plan, "bike": bike, "open": True})
return ride_id
def finish(self, ride_id: str, minutes: int) -> str:
ride = self.store.load(ride_id)
pence = max(0, minutes - 30) * 10 if ride["plan"] == "member" else 100 + minutes * 10
ride["open"] = False
self.store.save(ride_id, ride)
paid = self.payments.charge(ride["rider"], pence)
return f"{ride['rider']} {ride_id}: £{pence // 100}.{pence % 100:02d} {'paid' if paid else 'payment failed'}"
# ================= Adapters: outside the hexagon, each plugged into a port. =================
class DockHardware(BikeLocks): # would talk to the dock's controller over the network
def __init__(self, bikes: dict[str, list[str]]) -> None:
self.bikes = bikes
def release(self, dock: str) -> str:
return self.bikes[dock].pop(0)
class InMemoryRides(RideStore): # would be a database adapter in production
def __init__(self) -> None:
self.rows: dict[str, dict] = {}
def save(self, ride_id: str, ride: dict) -> None:
self.rows[ride_id] = dict(ride)
def load(self, ride_id: str) -> dict:
return dict(self.rows[ride_id])
def open_rides(self) -> list[str]:
return [k for k, v in self.rows.items() if v["open"]]
class CardProvider(Payments):
def charge(self, rider: str, pence: int) -> bool:
return True
class DecliningPayments(Payments): # a test adapter
def charge(self, rider: str, pence: int) -> bool:
return False
# Two driving (inbound) adapters for the same core.
def http_adapter(core: RentBikes, method: str, path: str, body: dict) -> str:
if method == "POST" and path == "/unlock":
return "201 " + core.unlock(body["rider"], body["plan"], body["dock"])
if method == "POST" and path.endswith("/return"):
return "200 " + core.finish(path.split("/")[2], body["minutes"])
return "404"
def nightly_job(core: RentBikes, store: RideStore) -> list[str]:
# Rides still open at 3 a.m. are closed at the 12-hour cap.
return [core.finish(r, 720) for r in store.open_rides()]
if __name__ == "__main__":
store = InMemoryRides()
core = RentBikes(DockHardware({"D12": ["B7", "B9"]}), store, CardProvider())
print(http_adapter(core, "POST", "/unlock", {"rider": "Mia", "plan": "member", "dock": "D12"}))
print(http_adapter(core, "POST", "/rides/R1/return", {"minutes": 42}))
core.unlock("Leo", "payg", "D12")
print("nightly job:", nightly_job(core, store))
test_core = RentBikes(DockHardware({"T": ["B1"]}), InMemoryRides(), DecliningPayments())
print("test with a declining payment adapter:", test_core.finish(test_core.unlock("Ana", "payg", "T"), 10))
// ================= The hexagon: Pedal's core. It imports nothing from outside. =================
// Outbound ports: what the core needs from the world, in the core's words.
interface Ride { rider: string; plan: string; bike: string; open: boolean; }
interface BikeLocks { release(dock: string): string; }
interface RideStore { save(id: string, ride: Ride): void; load(id: string): Ride; openRides(): string[]; }
interface Payments { charge(rider: string, pence: number): boolean; }
// Inbound port: what the world can ask the core to do.
class RentBikes {
private count = 0;
constructor(private locks: BikeLocks, private store: RideStore, private payments: Payments) {}
unlock(rider: string, plan: string, dock: string): string {
const bike = this.locks.release(dock);
const id = `R${++this.count}`;
this.store.save(id, { rider, plan, bike, open: true });
return id;
}
finish(id: string, minutes: number): string {
const ride = this.store.load(id);
const pence = ride.plan === "member" ? Math.max(0, minutes - 30) * 10 : 100 + minutes * 10;
ride.open = false;
this.store.save(id, ride);
const paid = this.payments.charge(ride.rider, pence);
return `${ride.rider} ${id}: £${Math.floor(pence / 100)}.${String(pence % 100).padStart(2, "0")} ${paid ? "paid" : "payment failed"}`;
}
}
// ================= Adapters: outside the hexagon, each plugged into a port. =================
class DockHardware implements BikeLocks { // would talk to the dock's controller over the network
constructor(private bikes: Record<string, string[]>) {}
release(dock: string): string { return this.bikes[dock].shift() as string; }
}
class InMemoryRides implements RideStore { // would be a database adapter in production
private rows = new Map<string, Ride>();
save(id: string, ride: Ride): void { this.rows.set(id, { ...ride }); }
load(id: string): Ride { return { ...(this.rows.get(id) as Ride) }; }
openRides(): string[] { return [...this.rows].filter(([, r]) => r.open).map(([k]) => k); }
}
class CardProvider implements Payments { charge(): boolean { return true; } }
class DecliningPayments implements Payments { charge(): boolean { return false; } } // a test adapter
// Two driving (inbound) adapters for the same core.
function httpAdapter(core: RentBikes, method: string, path: string, body: { rider?: string; plan?: string; dock?: string; minutes?: number }): string {
if (method === "POST" && path === "/unlock") return "201 " + core.unlock(body.rider ?? "", body.plan ?? "", body.dock ?? "");
if (method === "POST" && path.endsWith("/return")) return "200 " + core.finish(path.split("/")[2], body.minutes ?? 0);
return "404";
}
// Rides still open at 3 a.m. are closed at the 12-hour cap.
const nightlyJob = (core: RentBikes, store: RideStore): string[] => store.openRides().map((r) => core.finish(r, 720));
const store = new InMemoryRides();
const core = new RentBikes(new DockHardware({ D12: ["B7", "B9"] }), store, new CardProvider());
console.log(httpAdapter(core, "POST", "/unlock", { rider: "Mia", plan: "member", dock: "D12" }));
console.log(httpAdapter(core, "POST", "/rides/R1/return", { minutes: 42 }));
core.unlock("Leo", "payg", "D12");
console.log("nightly job: [" + nightlyJob(core, store).map((s) => `'${s}'`).join(", ") + "]");
const testCore = new RentBikes(new DockHardware({ T: ["B1"] }), new InMemoryRides(), new DecliningPayments());
console.log("test with a declining payment adapter: " + testCore.finish(testCore.unlock("Ana", "payg", "T"), 10));import java.util.ArrayList;
import java.util.HashMap;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.stream.Collectors;
// ================= The hexagon: Pedal's core. It imports nothing from outside. =================
class Ride {
String rider, plan, bike; boolean open;
Ride(String rider, String plan, String bike, boolean open) { this.rider = rider; this.plan = plan; this.bike = bike; this.open = open; }
Ride copy() { return new Ride(rider, plan, bike, open); }
}
// Outbound ports: what the core needs from the world, in the core's words.
interface BikeLocks { String release(String dock); }
interface RideStore { void save(String id, Ride ride); Ride load(String id); List<String> openRides(); }
interface Payments { boolean charge(String rider, int pence); }
// Inbound port: what the world can ask the core to do.
class RentBikes {
private final BikeLocks locks; private final RideStore store; private final Payments payments;
private int count = 0;
RentBikes(BikeLocks locks, RideStore store, Payments payments) { this.locks = locks; this.store = store; this.payments = payments; }
String unlock(String rider, String plan, String dock) {
String bike = locks.release(dock);
String id = "R" + (++count);
store.save(id, new Ride(rider, plan, bike, true));
return id;
}
String finish(String id, int minutes) {
Ride ride = store.load(id);
int pence = ride.plan.equals("member") ? Math.max(0, minutes - 30) * 10 : 100 + minutes * 10;
ride.open = false;
store.save(id, ride);
boolean paid = payments.charge(ride.rider, pence);
return ride.rider + " " + id + ": £" + (pence / 100) + "." + String.format("%02d", pence % 100) + " " + (paid ? "paid" : "payment failed");
}
}
// ================= Adapters: outside the hexagon, each plugged into a port. =================
class DockHardware implements BikeLocks { // would talk to the dock's controller over the network
private final Map<String, List<String>> bikes = new HashMap<>();
DockHardware(String dock, List<String> b) { bikes.put(dock, new ArrayList<>(b)); }
public String release(String dock) { return bikes.get(dock).remove(0); }
}
class InMemoryRides implements RideStore { // would be a database adapter in production
private final Map<String, Ride> rows = new LinkedHashMap<>();
public void save(String id, Ride ride) { rows.put(id, ride.copy()); }
public Ride load(String id) { return rows.get(id).copy(); }
public List<String> openRides() { return rows.entrySet().stream().filter(e -> e.getValue().open).map(Map.Entry::getKey).collect(Collectors.toList()); }
}
class CardProvider implements Payments { public boolean charge(String rider, int pence) { return true; } }
class DecliningPayments implements Payments { public boolean charge(String rider, int pence) { return false; } } // a test adapter
public class Main {
// Two driving (inbound) adapters for the same core.
static String httpAdapter(RentBikes core, String method, String path, Map<String, String> body) {
if (method.equals("POST") && path.equals("/unlock")) return "201 " + core.unlock(body.get("rider"), body.get("plan"), body.get("dock"));
if (method.equals("POST") && path.endsWith("/return")) return "200 " + core.finish(path.split("/")[2], Integer.parseInt(body.get("minutes")));
return "404";
}
// Rides still open at 3 a.m. are closed at the 12-hour cap.
static List<String> nightlyJob(RentBikes core, RideStore store) { return store.openRides().stream().map(r -> core.finish(r, 720)).collect(Collectors.toList()); }
public static void main(String[] args) {
InMemoryRides store = new InMemoryRides();
RentBikes core = new RentBikes(new DockHardware("D12", List.of("B7", "B9")), store, new CardProvider());
System.out.println(httpAdapter(core, "POST", "/unlock", Map.of("rider", "Mia", "plan", "member", "dock", "D12")));
System.out.println(httpAdapter(core, "POST", "/rides/R1/return", Map.of("minutes", "42")));
core.unlock("Leo", "payg", "D12");
System.out.println("nightly job: [" + nightlyJob(core, store).stream().map(s -> "'" + s + "'").collect(Collectors.joining(", ")) + "]");
RentBikes testCore = new RentBikes(new DockHardware("T", List.of("B1")), new InMemoryRides(), new DecliningPayments());
System.out.println("test with a declining payment adapter: " + testCore.finish(testCore.unlock("Ana", "payg", "T"), 10));
}
}Output (the same in all three languages)
201 R1 200 Mia R1: £1.20 paid nightly job: ['Leo R2: £73.00 paid'] test with a declining payment adapter: Ana R1: £2.00 payment failed
6. Try it
Allowed, or not?
Eight imports in Pedal's codebase. The only rule: dependencies point into the core. Judge each one, then check.
7. What it costs, and when to pick another style
What you get
- Rules that outlive technology: frameworks, databases and vendors change at the edges.
- Fast tests: the core runs with in-memory fakes, no infrastructure.
- Many ways in: an HTTP API, a CLI, a job and a test drive the same use cases.
- Late decisions: the database can be chosen after the rules work.
What you pay
- More types: ports and adapters for every external thing.
- Translation code: mapping between core objects and transport or storage shapes.
- Overkill for CRUD: if there are no real rules, the hexagon wraps nothing.
- Discipline: the rule is easy to break with one import.
| If… | Consider instead |
|---|---|
| The app is mostly forms over data | Layered |
| You want the same idea with named rings and use cases | Clean or onion |
| Variation is per customer or city, not per technology | Microkernel |
| Parts need separate deployment | Hexagonal services inside microservices |
8. Signs you've outgrown it
- Most ports have exactly one adapter that will never change, and nobody uses the fakes.
- Adapters contain more logic than the core.
- The mapping code between layers is bigger than the rules it protects.
9. Remember it and say it
How this hook works: what “The laptop decides the socket” means
What the slogan means, phrase by phrase
- “The laptop”
- The core: Pedal's rules.
- “decides the socket”
- Ports are defined by the core, never by a vendor.
- “plugs in from outside”
- Adapters for web, storage, hardware and tests.
Why this picture fits: A laptop's standard sockets: any charger or screen that fits works, and a cheap adapter can stand in for testing.
The story → the concept
| The laptop | Pedal |
|---|---|
| The laptop | RentBikes, the core |
| Sockets | BikeLocks, RideStore, Payments |
| Chargers and screens | DockHardware, a database, the card provider |
| A test plug | DecliningPayments |
Watch it on an example
- The phone app and the nightly job drive the same core.
- The dock protocol could change completely; only DockHardware would.
- A failed payment is tested in milliseconds with a fake adapter.
The trap it saves you from
Letting the core import one framework class "just for convenience".
It still works when…
Inside services: each microservice is often a small hexagon.
In an interview or design review
- Name the ports: "The core has an inbound RentBikes port and outbound ports for dock locks, storage and payments."
- Explain the direction: adapters depend on ports; the core depends on nothing outside.
- Use it for testing claims: "The pricing and unlock rules run in unit tests with fake adapters."
- Know when not to: for a thin CRUD service, a layered design is simpler.
10. Check yourself
Who defines a port's interface?
Which is a driving (inbound) adapter?
The web adapter decides whether a rider gets a member discount. What's wrong?
How is hexagonal different from layered?
11. Go deeper
| Resource | Why |
|---|---|
| Hexagonal Architecture (Alistair Cockburn) | The original description of ports and adapters. |
| Architecture 07a: Inside a service | Hexagonal, onion and clean compared, with a payment-provider swap. |
| LLD 03e: Dependency inversion | The principle that makes ports possible. |