InterviewKata
Architecture styles · 03 of 16 · Inside one application

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.

See the patternThe class-level patterns behind ports and adapters: 06 Adapter · 24 Dependency injection · 25 Repository and unit of work.

1. In one picture

A laptop has a few standard sockets: power, USB-C, audio. The laptop decides their shape. Chargers, screens and headphones from any maker fit as long as they match the socket, and you can test the laptop with a cheap adapter instead of the expensive screen. The laptop never changes to suit a particular charger.

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.

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

RuleWhy it existsBroken when…
The core imports nothing from outsideBusiness 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 languageSwapping a vendor never changes the core's interface.The payment port has methods named after the provider's SDK.
Adapters translate, nothing moreRules stay in one place.The web adapter decides who gets a member discount.
Every adapter can be replaced by a fakeThe core is tested quickly, with no network or database.A test of the pricing rule needs a running database.

4. How it goes wrong

Ports that mirror a vendorAn interface copied from one payment provider's API isn't a port; it locks the core to that provider.
Logic in adaptersValidation and decisions creep into controllers and repositories until the core is empty.
A port per tableDozens of fine-grained repository ports for a simple app: ceremony without benefit.
Hexagonal in name onlyFolders called ports and adapters, but the core still imports the framework.

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 dataLayered
You want the same idea with named rings and use casesClean or onion
Variation is per customer or city, not per technologyMicrokernel
Parts need separate deploymentHexagonal services inside microservices

8. Signs you've outgrown it

9. Remember it and say it

Memory hook: "The laptop decides the socket"The core declares ports in its own language; technology plugs in from outside.
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 laptopPedal
The laptopRentBikes, the core
SocketsBikeLocks, RideStore, Payments
Chargers and screensDockHardware, a database, the card provider
A test plugDecliningPayments

Watch it on an example

  1. The phone app and the nightly job drive the same core.
  2. The dock protocol could change completely; only DockHardware would.
  3. 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

10. Check yourself

Who defines a port's interface?

That's what keeps vendors swappable.

Which is a driving (inbound) adapter?

It calls into the core, like the HTTP API does.

The web adapter decides whether a rider gets a member discount. What's wrong?

Adapters translate; rules belong in the core.

How is hexagonal different from layered?

The dependency on storage is inverted.

11. Go deeper

ResourceWhy
Hexagonal Architecture (Alistair Cockburn)The original description of ports and adapters.
Architecture 07a: Inside a serviceHexagonal, onion and clean compared, with a payment-provider swap.
LLD 03e: Dependency inversionThe principle that makes ports possible.