Skip to content
wannacode

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());
}
Output
The test fails: no connection to db.shop.local

The 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. CardPayAdapter turns €120 into 12000 cents, calls the SDK and translates "APPROVED" into true. 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 words
interface 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 outside
class 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";
}
}
In Java the port is an 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 provider
class 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 it
class 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));
Output
CardPay: A-7 12000 EUR → APPROVED
A-7: paid
CardPay: A-8 4000 EUR → DECLINED
A-8: declined
A-7: already paid

// The ports: what the core needs, in its own words
interface 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 outside
class 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";
}
}
Step by step, in words
  1. The core is Checkout with two ports. The ports belong to the core and speak its words: euros, yes or no.
  2. Checkout knows only the ports. It does not know who stands behind them.
  3. In the shop, adapters plug into the ports: CardPayAdapter in front of the provider, and the order storage.
  4. A-7 for €120: the adapter translates it into 12000 EUR cents, the provider says APPROVED, and the core hears true.
  5. For the test the adapters are swapped: a double instead of the provider. The core is the same, with not one line changed.
  6. The test pays A-7 twice: "paid", then "already paid". The double recorded one charge. No network, no database.
  7. 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);
Output
paid
already paid
Charges: 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);
Answer

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: the Payments port and the CardPay SDK;
  • FakePayments and InMemoryOrders: 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 words
interface Payments {
String charge(String orderId, int euros);
}
interface Orders {
Order find(String id);
void save(Order order);
}
// The core: the rules, with nothing from outside
class 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 approves
class 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"));
Output
A-7: declined

The double approves, and the order is declined. Which line of the core is the bug on?

Pick the line with the bug.

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 (OrderEntity for the database, OrderDto for 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 no
class 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):

Output
PayLater: A-7 120.00 EUR → ok
A-7: paid
PayLater: A-8 40.00 EUR → declined
A-8: declined

More exercises

  • Notifications. Add a Notifier port 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

1. Whose is the Payments port?
2. The shop changes its payment provider. Which code is written again?
3. The core says if (status.equals("APPROVED")). What is broken?
4. Which one is a driving adapter, not a driven one?
5. A service has one endpoint that reads a row from the database and returns it, with no rules. Are a port and an adapter worth it?

Made by Ivo Gergov as part of Evo Tech's campaign for accessible programming education ·