Hexagonal and clean architecture
In short: Hexagonal architecture (ports and adapters) gives the core ports in its own words, while adapters for the database and external services plug in from outside, so dependencies point inwards.
Problem
The test that wants a bank
Paying for an order has rules: a paid order is not paid again, and a declined one does not become paid. Here is the core as the first team wrote it. It makes its own connection to the database and its own client for the payment provider CardPay:
class Checkout { private final SqlOrders orders = new SqlOrders("db.shop.local"); private final CardPayClient provider = new CardPayClient();
String pay(String id) { Order order = orders.find(id); if (order.paid) return "already paid"; var response = provider.charge(order.total * 100L, "EUR", id); if (!response.status().equals("APPROVED")) return "declined"; order.paid = true; orders.save(order); return "paid"; }}The CardPay client and the SQL repository are real classes from outside. In this lesson these "outside" classes are reduced to what they do in a test: there is no database and no network.
// The real classes from outside. Here there is no database and no network.class SqlOrders { SqlOrders(String host) { throw new IllegalStateException("no connection to " + host); }
Order find(String id) { return null; }
void save(Order order) {}}The test of the rule "a paid order is not paid again":
try { var checkout = new Checkout(); System.out.println(checkout.pay("A-7"));} catch (IllegalStateException e) { System.out.println("The test fails: " + e.getMessage());}The test fails: no connection to db.shop.localThe test never reaches the rule. It fails on the first line, because it wants a database. With a database it would want the network and a CardPay key, and every run would really pay. And more: the core holds cents, "EUR" and "APPROVED", the words of CardPay. When the shop changes provider, the core is rewritten.
The idea
Ports and adapters
In layers the domain stood above the data. Hexagonal architecture (ports and adapters) goes further: everything outside is equally "outside", the database, the provider and the screen alike. In the middle is the core with the rules.
- A port is an interface of the core, in its words: "pay this order for €120, tell me yes or no". The port belongs to the core, not to the provider;
- an adapter is a class outside that implements the port and translates.
CardPayAdapterturns €120 into 12000 cents, calls the SDK and translates"APPROVED"intotrue. It is the Adapter pattern, placed on every border of the application.
Adapters come in two kinds. Driving adapters call the core: the controller, the test, the console command. Driven adapters are called by the core through a port: payment, orders, mail.
This is DIP for the whole application: not for one class, but for every border. And the core gets its adapters from outside, in the constructor, as in the lesson Singleton and Dependency Injection.
See it
The core
The two ports, the order and the core. Nothing here knows about CardPay, SQL or cents:
// The ports: what the core needs, in its own wordsinterface Payments { boolean charge(String orderId, int euros);}
interface Orders { Order find(String id);
void save(Order order);}
class Order { final String id; final int total; boolean paid;
Order(String id, int total) { this.id = id; this.total = total; }}
// The core: the rules, with nothing from outsideclass Checkout { private final Orders orders; private final Payments payments;
Checkout(Orders orders, Payments payments) { this.orders = orders; this.payments = payments; }
String pay(String id) { Order order = orders.find(id); if (order.paid) return "already paid"; boolean ok = payments.charge(id, order.total); if (!ok) return "declined"; order.paid = true; orders.save(order); return "paid"; }}interface, and the adapter implements it with implements. The compiler makes sure every adapter has charge.See it
The adapters and the shop
The provider's SDK as the program sees it. The network is replaced with recorded answers, and the client prints what it received:
// The provider's SDK. Here the network is replaced// with recorded answers.class CardPayClient { private final Deque<String> answers;
CardPayClient(String... answers) { this.answers = new ArrayDeque<>(List.of(answers)); }
CardPayResponse charge(long cents, String currency, String ref) { String status = answers.removeFirst(); System.out.printf(" CardPay: %s %d %s → %s%n", ref, cents, currency, status); return new CardPayResponse(status); }}
record CardPayResponse(String status) {}The CardPay adapter, the test double and the orders in memory:
// The adapter translates the words of the core for the providerclass CardPayAdapter implements Payments { private final CardPayClient client;
CardPayAdapter(CardPayClient client) { this.client = client; }
public boolean charge(String orderId, int euros) { var response = client.charge(euros * 100L, "EUR", orderId); return response.status().equals("APPROVED"); }}
// The test double: it records and answers as you tell itclass FakePayments implements Payments { final List<String> charged = new ArrayList<>(); private final boolean approve;
FakePayments(boolean approve) { this.approve = approve; }
public boolean charge(String orderId, int euros) { charged.add(orderId + " " + euros + " €"); return approve; }}
// Orders in memory (in the shop: SqlOrders)class InMemoryOrders implements Orders { private final Map<String, Order> rows = new HashMap<>();
public Order find(String id) { return rows.get(id); }
public void save(Order order) { rows.put(order.id, order); }}Wiring up the shop. Only here is it decided which adapter goes into which port:
var orders = new InMemoryOrders();orders.save(new Order("A-7", 120));orders.save(new Order("A-8", 40));var client = new CardPayClient("APPROVED", "DECLINED");var payments = new CardPayAdapter(client);var checkout = new Checkout(orders, payments);for (String id : List.of("A-7", "A-8", "A-7")) System.out.println(id + ": " + checkout.pay(id)); CardPay: A-7 12000 EUR → APPROVEDA-7: paid CardPay: A-8 4000 EUR → DECLINEDA-8: declinedA-7: already paid// The ports: what the core needs, in its own wordsinterface Payments { boolean charge(String orderId, int euros);}
interface Orders { Order find(String id);
void save(Order order);}
class Order { final String id; final int total; boolean paid;
Order(String id, int total) { this.id = id; this.total = total; }}
// The core: the rules, with nothing from outsideclass Checkout { private final Orders orders; private final Payments payments;
Checkout(Orders orders, Payments payments) { this.orders = orders; this.payments = payments; }
String pay(String id) { Order order = orders.find(id); if (order.paid) return "already paid"; boolean ok = payments.charge(id, order.total); if (!ok) return "declined"; order.paid = true; orders.save(order); return "paid"; }}// The ports: what the core needs, in its own wordsinterface Payments { boolean charge(String orderId, int euros);}
interface Orders { Order find(String id);
void save(Order order);}
class Order { final String id; final int total; boolean paid;
Order(String id, int total) { this.id = id; this.total = total; }}
// The core: the rules, with nothing from outsideclass Checkout { private final Orders orders; private final Payments payments;
Checkout(Orders orders, Payments payments) { this.orders = orders; this.payments = payments; }
String pay(String id) { Order order = orders.find(id); if (order.paid) return "already paid"; boolean ok = payments.charge(id, order.total); if (!ok) return "declined"; order.paid = true; orders.save(order); return "paid"; }}var orders = new InMemoryOrders();orders.save(new Order("A-7", 120));orders.save(new Order("A-8", 40));var client = new CardPayClient("APPROVED", "DECLINED");var payments = new CardPayAdapter(client);var checkout = new Checkout(orders, payments);for (String id : List.of("A-7", "A-8", "A-7")) System.out.println(id + ": " + checkout.pay(id));var orders = new InMemoryOrders();orders.save(new Order("A-7", 120));var payments = new FakePayments(true);var checkout = new Checkout(orders, payments);System.out.println(checkout.pay("A-7"));System.out.println(checkout.pay("A-7"));String charged = String.join(", ", payments.charged);System.out.println("Charges: " + charged);Step by step, in words
- The core is Checkout with two ports. The ports belong to the core and speak its words: euros, yes or no.
- Checkout knows only the ports. It does not know who stands behind them.
- In the shop, adapters plug into the ports: CardPayAdapter in front of the provider, and the order storage.
- A-7 for €120: the adapter translates it into 12000 EUR cents, the provider says APPROVED, and the core hears true.
- For the test the adapters are swapped: a double instead of the provider. The core is the same, with not one line changed.
- The test pays A-7 twice: "paid", then "already paid". The double recorded one charge. No network, no database.
- The arrows point inwards: the adapters depend on the ports of the core, and the core depends on nothing outside.
The core says "€120", and CardPay receives "12000 EUR". The translation is in the adapter and only there. In the real shop the orders are in a database: the SqlOrders adapter is the repository from the lesson Repository, and here they are in memory, because the lesson has no database.
See it
The test with nothing from outside
The same core, other adapters: the double instead of CardPay. No network, no database, no key:
var orders = new InMemoryOrders();orders.save(new Order("A-7", 120));var payments = new FakePayments(true);var checkout = new Checkout(orders, payments);System.out.println(checkout.pay("A-7"));System.out.println(checkout.pay("A-7"));String charged = String.join(", ", payments.charged);System.out.println("Charges: " + charged);paidalready paidCharges: A-7 120 €The rule "a paid order is not paid again" is checked, and the double shows that the provider saw only one charge.
Try it
Predict the output
The double now declines every payment. What does the test print?
var orders = new InMemoryOrders();orders.save(new Order("A-8", 40));var payments = new FakePayments(false);var checkout = new Checkout(orders, payments);System.out.println(checkout.pay("A-8"));System.out.println(checkout.pay("A-8"));String charged = String.join(", ", payments.charged);System.out.println("Charges: " + charged);A declined order does not become paid, so the second attempt reaches the provider again and is declined again. The double recorded two attempts. This way a test checks a rule that would cost two real charges with the real provider.
The idea
The dependency rule
The direction from layers becomes the dependency rule here: dependencies in the code point inwards, towards the core. The adapter knows the port, and the core does not know the adapter. Who imports whom:
- the core: only itself (
Checkout,Order, the ports); CardPayAdapter: thePaymentsport and the CardPay SDK;FakePaymentsandInMemoryOrders: only the ports;- the wiring in main: everything, because it decides.
Clean architecture (Robert Martin) draws the same thing as circles: business rules in the middle, use cases around them (Checkout.pay), then adapters, and the frameworks, the database and the network on the outside. The names differ, the rule is one: the arrows point to the middle.
The rule is not broken only by an import. It is also broken when a word from outside gets into the core.
Try it
Find the bug
Someone decided that the port may return the provider's status directly:
// The ports: what the core needs, in its own wordsinterface Payments { String charge(String orderId, int euros);}
interface Orders { Order find(String id);
void save(Order order);}// The core: the rules, with nothing from outsideclass Checkout { private final Orders orders; private final Payments payments;
Checkout(Orders orders, Payments payments) { this.orders = orders; this.payments = payments; }
String pay(String id) { Order order = orders.find(id); if (order.paid) return "already paid"; String status = payments.charge(id, order.total); if (!status.equals("APPROVED")) return "declined"; order.paid = true; orders.save(order); return "paid"; }}The test passes a double that approves:
// The test double: it approvesclass FakePayments implements Payments { public String charge(String orderId, int euros) { return "OK"; }}var orders = new InMemoryOrders();orders.save(new Order("A-7", 120));var checkout = new Checkout(orders, new FakePayments());System.out.println("A-7: " + checkout.pay("A-7"));A-7: declinedThe double approves, and the order is declined. Which line of the core is the bug on?
Pick the line with the bug.
"APPROVED" is a word of CardPay, and it has got into the core. The core imports no CardPay class, yet it depends on CardPay: the double must know that CardPay says "APPROVED", and another provider that says "SUCCESS" breaks the core. The port should use the words of the core (yes or no), and translating the status is the adapter's job.
The idea
When it is needless
Ports and adapters cost an interface and at least two classes for every border. They are not worth it when:
- there are no rules. A service that reads a row and returns it will have a port, an adapter and a translation between the same data, with nothing to test;
- the port always has one adapter and tests never swap it. Then the interface is just one more file;
- every adapter has its own object for the order (
OrderEntityfor the database,OrderDtofor HTTP), and translating between them is more code than the rules.
They are worth it when the rules must be tested without the network and the database, when the outside thing changes (a payment provider, mail, a warehouse), or when one core is called from several places: the web, the mobile app, a nightly job.
More precisely, if you are curious
The name. Alistair Cockburn called the idea "hexagonal" (2005), because he drew the core as a hexagon with room for many ports. The six sides mean nothing. Onion architecture (Jeffrey Palermo) and clean architecture (Robert Martin) are variants of the same rule.
In frameworks. Spring and ASP.NET Core wire the core and the adapters with a DI container: the port is an interface, and the container passes the adapter. In Python (FastAPI, Django) and Node (Express, Nest) the wiring is often by hand at the start of the program, as in the lesson. Tests in a real project also use libraries for doubles: Mockito, Moq, unittest.mock, node:test mocks. The hand-written double of the lesson is often clearer.
How much is "outside". The clock and randomness are outside too. A rule "the order expires after 30 minutes" cannot be tested if the core asks the system clock itself. A Clock port with a double that says "it is 10:30 now" makes it possible.
Exercise
A second provider
Solve it in your editor.
The shop adds a second provider, PayLater. Its SDK wants the amount as text ("120.00 EUR") and answers yes or no:
// The second provider's SDK: the amount as text, the answer yes or noclass PayLaterClient { private final Deque<Boolean> answers;
PayLaterClient(Boolean... answers) { this.answers = new ArrayDeque<>(List.of(answers)); }
boolean submit(String reference, String amount) { boolean ok = answers.removeFirst(); System.out.printf(" PayLater: %s %s → %s%n", reference, amount, ok ? "ok" : "declined"); return ok; }}Write an adapter PayLaterAdapter for the port Payments and pass it to the same core. Checkout must not change. Pay A-7 (PayLater says yes) and A-8 (PayLater says no):
PayLater: A-7 120.00 EUR → okA-7: paid PayLater: A-8 40.00 EUR → declinedA-8: declinedMore exercises
- Notifications. Add a
Notifierport with a method for the notification "Order A-7 is paid". An adapter for the console and a double that records notifications. Check that a declined order sends no notification. - The clock. A paid order can be returned within 14 days. Add a port for the clock and a test with a double that says "today is day 15".
Test
Check yourself
Related lessons
Section titled "Related lessons"- Builds on: Adapter and Facade · Repository · SOLID without dogma · Layers
- Continues in: Monolith, modular monolith, services · ADR: architecture decision records · Architecture review
- Tasks on this topic: Software architecture