Clean and onion
Clean and onion architecture take hexagonal's inward rule and divide the inside into named rings: entities, use cases, adapters and frameworks. This page builds Pedal's return as a use case that knows nothing of storage or display formats, and shows the same result rendered two ways.
1. In one picture
Onion architecture (Jeffrey Palermo, 2008) and clean architecture (Robert C. Martin, 2012) organise code as concentric rings with one rule: source-code dependencies point inward only. Inner rings know nothing about outer ones. They're close relatives of hexagonal: the same inward rule, with the inside divided into named rings.
| Ring (clean's names) | Holds | In Pedal |
|---|---|---|
| 1 · Entities | Business rules that would exist without any software | A Plan prices minutes; a Ride can be finished once |
| 2 · Use cases | What the application does, step by step, and the interfaces it needs | ReturnBike, with a RideGateway and a ReturnPresenter it defines |
| 3 · Interface adapters | Translation to and from outside formats | JSON and receipt presenters, a gateway that maps database rows |
| 4 · Frameworks and drivers | The web server, the database, UI frameworks | Whatever the team picked this year |
2. Pedal built this way
Two returns through the same use case: one rendered as JSON for the app, one as a printed receipt. Only the presenter differs.
The use case doesn't return JSON or a receipt; it hands a plain result to whichever presenter it was given. That's how the inner rings stay unaware of how results are shown, the extra step clean architecture adds over a basic hexagon.
3. The rules
| Rule | Why it exists | Broken when… |
|---|---|---|
| Dependencies point inward only | Inner rules never change because a framework did. | An entity imports the ORM, or a use case imports a web framework class. |
| Inner rings define the interfaces they need | The direction holds even when inner code calls outer code at run time. | The use case calls a concrete repository class from the adapters ring. |
| Data crossing a boundary is plain | No outer type leaks inward. | A database row object is passed into an entity method. |
| Entities don't know use cases | The core business rules are reusable by any application. | Ride.finish() sends an email. |
4. How it goes wrong
5. The code
Pedal in four rings. Plan and Ride are entities; ReturnBike is a use case that defines the gateway and presenter it needs; a row gateway and two presenters are adapters; a dictionary stands in for the database.
from __future__ import annotations
from abc import ABC, abstractmethod
from dataclasses import dataclass
# ===== Ring 1 · Entities: Pedal's rules that would exist even without software =====
@dataclass
class Plan:
name: str
free_minutes: int
unlock_fee: int
def price(self, minutes: int) -> int:
return self.unlock_fee + max(0, minutes - self.free_minutes) * 10
@dataclass
class Ride:
id: str
rider: str
plan: Plan
minutes: int = 0
price: int = 0
open: bool = True
def finish(self, minutes: int) -> None:
if not self.open:
raise ValueError("already finished")
self.minutes, self.price, self.open = minutes, self.plan.price(minutes), False
# ===== Ring 2 · Use cases: what the application does. Defines the interfaces it needs. =====
@dataclass
class ReturnResult:
ride_id: str
rider: str
minutes: int
price: int
free_minutes: int
class RideGateway(ABC): # owned by the use case, implemented further out
@abstractmethod
def find(self, ride_id: str) -> Ride: ...
@abstractmethod
def store(self, ride: Ride) -> None: ...
class ReturnPresenter(ABC): # how the use case hands its result outward
@abstractmethod
def present(self, result: ReturnResult) -> str: ...
class ReturnBike:
def __init__(self, rides: RideGateway, presenter: ReturnPresenter) -> None:
self.rides, self.presenter = rides, presenter
def run(self, ride_id: str, minutes: int) -> str:
ride = self.rides.find(ride_id)
ride.finish(minutes)
self.rides.store(ride)
return self.presenter.present(ReturnResult(ride.id, ride.rider, minutes, ride.price, ride.plan.free_minutes))
# ===== Ring 3 · Interface adapters: convert between the use cases and the outside formats =====
def money(p: int) -> str:
return f"{p // 100}.{p % 100:02d}"
class JsonPresenter(ReturnPresenter):
def present(self, r: ReturnResult) -> str:
return f'{{"ride": "{r.ride_id}", "minutes": {r.minutes}, "price": "{money(r.price)}"}}'
class ReceiptPresenter(ReturnPresenter):
def present(self, r: ReturnResult) -> str:
free = f" ({r.free_minutes} free with membership)" if r.free_minutes else ""
return f"Thanks {r.rider}, {r.minutes} minutes, £{money(r.price)}{free}"
class TableRideGateway(RideGateway):
PLANS = {"member": Plan("member", 30, 0), "payg": Plan("payg", 0, 100)}
def __init__(self, table: dict[str, dict]) -> None:
self.table = table # rows as the database driver returns them
def find(self, ride_id: str) -> Ride:
row = self.table[ride_id]
return Ride(ride_id, row["rider"], self.PLANS[row["plan"]], open=row["open"])
def store(self, ride: Ride) -> None:
self.table[ride.id] = {"rider": ride.rider, "plan": ride.plan.name, "open": ride.open, "price": ride.price}
# ===== Ring 4 · Frameworks and drivers: the database, the web server (stand-ins here) =====
if __name__ == "__main__":
database = {"R1": {"rider": "Mia", "plan": "member", "open": True}, "R2": {"rider": "Leo", "plan": "payg", "open": True}}
gateway = TableRideGateway(database)
print("web:", ReturnBike(gateway, JsonPresenter()).run("R1", 42))
print("receipt:", ReturnBike(gateway, ReceiptPresenter()).run("R2", 15))
print("dependencies: frameworks -> adapters -> use cases -> entities")
// ===== Ring 1 · Entities: Pedal's rules that would exist even without software =====
class Plan {
constructor(readonly name: string, readonly freeMinutes: number, readonly unlockFee: number) {}
price(minutes: number): number { return this.unlockFee + Math.max(0, minutes - this.freeMinutes) * 10; }
}
class Ride {
minutes = 0; price = 0;
constructor(readonly id: string, readonly rider: string, readonly plan: Plan, public open = true) {}
finish(minutes: number): void {
if (!this.open) throw new Error("already finished");
this.minutes = minutes; this.price = this.plan.price(minutes); this.open = false;
}
}
// ===== Ring 2 · Use cases: what the application does. Defines the interfaces it needs. =====
interface ReturnResult { rideId: string; rider: string; minutes: number; price: number; freeMinutes: number; }
interface RideGateway { find(id: string): Ride; store(ride: Ride): void; } // owned by the use case, implemented further out
interface ReturnPresenter { present(result: ReturnResult): string; } // how the use case hands its result outward
class ReturnBike {
constructor(private rides: RideGateway, private presenter: ReturnPresenter) {}
run(id: string, minutes: number): string {
const ride = this.rides.find(id);
ride.finish(minutes);
this.rides.store(ride);
return this.presenter.present({ rideId: ride.id, rider: ride.rider, minutes, price: ride.price, freeMinutes: ride.plan.freeMinutes });
}
}
// ===== Ring 3 · Interface adapters: convert between the use cases and the outside formats =====
const money = (p: number): string => `${Math.floor(p / 100)}.${String(p % 100).padStart(2, "0")}`;
class JsonPresenter implements ReturnPresenter {
present(r: ReturnResult): string { return `{"ride": "${r.rideId}", "minutes": ${r.minutes}, "price": "${money(r.price)}"}`; }
}
class ReceiptPresenter implements ReturnPresenter {
present(r: ReturnResult): string {
const free = r.freeMinutes ? ` (${r.freeMinutes} free with membership)` : "";
return `Thanks ${r.rider}, ${r.minutes} minutes, £${money(r.price)}${free}`;
}
}
interface Row { rider: string; plan: string; open: boolean; price?: number; }
class TableRideGateway implements RideGateway {
static PLANS: Record<string, Plan> = { member: new Plan("member", 30, 0), payg: new Plan("payg", 0, 100) };
constructor(private table: Record<string, Row>) {} // rows as the database driver returns them
find(id: string): Ride { const row = this.table[id]; return new Ride(id, row.rider, TableRideGateway.PLANS[row.plan], row.open); }
store(ride: Ride): void { this.table[ride.id] = { rider: ride.rider, plan: ride.plan.name, open: ride.open, price: ride.price }; }
}
// ===== Ring 4 · Frameworks and drivers: the database, the web server (stand-ins here) =====
const database: Record<string, Row> = { R1: { rider: "Mia", plan: "member", open: true }, R2: { rider: "Leo", plan: "payg", open: true } };
const gateway = new TableRideGateway(database);
console.log("web: " + new ReturnBike(gateway, new JsonPresenter()).run("R1", 42));
console.log("receipt: " + new ReturnBike(gateway, new ReceiptPresenter()).run("R2", 15));
console.log("dependencies: frameworks -> adapters -> use cases -> entities");import java.util.HashMap;
import java.util.Map;
// ===== Ring 1 · Entities: Pedal's rules that would exist even without software =====
record Plan(String name, int freeMinutes, int unlockFee) {
int price(int minutes) { return unlockFee + Math.max(0, minutes - freeMinutes) * 10; }
}
class Ride {
final String id, rider; final Plan plan;
int minutes = 0, price = 0; boolean open;
Ride(String id, String rider, Plan plan, boolean open) { this.id = id; this.rider = rider; this.plan = plan; this.open = open; }
void finish(int minutes) {
if (!open) throw new IllegalStateException("already finished");
this.minutes = minutes; this.price = plan.price(minutes); this.open = false;
}
}
// ===== Ring 2 · Use cases: what the application does. Defines the interfaces it needs. =====
record ReturnResult(String rideId, String rider, int minutes, int price, int freeMinutes) {}
interface RideGateway { Ride find(String id); void store(Ride ride); } // owned by the use case, implemented further out
interface ReturnPresenter { String present(ReturnResult result); } // how the use case hands its result outward
class ReturnBike {
private final RideGateway rides; private final ReturnPresenter presenter;
ReturnBike(RideGateway rides, ReturnPresenter presenter) { this.rides = rides; this.presenter = presenter; }
String run(String id, int minutes) {
Ride ride = rides.find(id);
ride.finish(minutes);
rides.store(ride);
return presenter.present(new ReturnResult(ride.id, ride.rider, minutes, ride.price, ride.plan.freeMinutes()));
}
}
// ===== Ring 3 · Interface adapters: convert between the use cases and the outside formats =====
class Fmt { static String money(int p) { return (p / 100) + "." + String.format("%02d", p % 100); } }
class JsonPresenter implements ReturnPresenter {
public String present(ReturnResult r) { return "{\"ride\": \"" + r.rideId() + "\", \"minutes\": " + r.minutes() + ", \"price\": \"" + Fmt.money(r.price()) + "\"}"; }
}
class ReceiptPresenter implements ReturnPresenter {
public String present(ReturnResult r) {
String free = r.freeMinutes() > 0 ? " (" + r.freeMinutes() + " free with membership)" : "";
return "Thanks " + r.rider() + ", " + r.minutes() + " minutes, £" + Fmt.money(r.price()) + free;
}
}
class TableRideGateway implements RideGateway {
static final Map<String, Plan> PLANS = Map.of("member", new Plan("member", 30, 0), "payg", new Plan("payg", 0, 100));
private final Map<String, Map<String, Object>> table; // rows as the database driver returns them
TableRideGateway(Map<String, Map<String, Object>> table) { this.table = table; }
public Ride find(String id) {
Map<String, Object> row = table.get(id);
return new Ride(id, (String) row.get("rider"), PLANS.get((String) row.get("plan")), (Boolean) row.get("open"));
}
public void store(Ride ride) {
Map<String, Object> row = new HashMap<>();
row.put("rider", ride.rider); row.put("plan", ride.plan.name()); row.put("open", ride.open); row.put("price", ride.price);
table.put(ride.id, row);
}
}
// ===== Ring 4 · Frameworks and drivers: the database, the web server (stand-ins here) =====
public class Main {
public static void main(String[] args) {
Map<String, Map<String, Object>> database = new HashMap<>();
database.put("R1", new HashMap<>(Map.of("rider", "Mia", "plan", "member", "open", true)));
database.put("R2", new HashMap<>(Map.of("rider", "Leo", "plan", "payg", "open", true)));
TableRideGateway gateway = new TableRideGateway(database);
System.out.println("web: " + new ReturnBike(gateway, new JsonPresenter()).run("R1", 42));
System.out.println("receipt: " + new ReturnBike(gateway, new ReceiptPresenter()).run("R2", 15));
System.out.println("dependencies: frameworks -> adapters -> use cases -> entities");
}
}Output (the same in all three languages)
web: {"ride": "R1", "minutes": 42, "price": "1.20"}
receipt: Thanks Leo, 15 minutes, £2.50
dependencies: frameworks -> adapters -> use cases -> entities
6. Try it
Which ring?
Eight pieces of Pedal's code. Put each in the ring where it belongs.
7. What it costs, and when to pick another style
What you get
- Rules protected from churn: frameworks can be replaced ring by ring.
- Clear places for code: everyone knows where a rule, a use case or a mapping goes.
- Testable centre: entities and use cases run with no infrastructure.
- Many front-ends: presenters let one use case serve an API, a receipt and a report.
What you pay
- Most ceremony of any in-process style: interfaces, data objects and mappers at every boundary.
- Slower start: more to set up before the first feature works.
- Easy to over-apply: small services rarely need four rings.
- Mapping bugs: every boundary is another place data can be copied wrongly.
| If… | Consider instead |
|---|---|
| The service is small or mostly CRUD | Layered |
| You want the inward rule with less structure | Hexagonal |
| Many customers or cities need different behaviour | Microkernel around a clean core |
| Teams must deploy separately | Clean services inside microservices |
8. Signs you've outgrown it
- Most use cases are one line that calls a repository.
- Mapping code between rings is larger than the rules it protects.
- Developers routinely skip rings to ship faster, and reviews let it through.
9. Remember it and say it
How this hook works: what “Rings point inward” means
What the slogan means, phrase by phrase
- “Rings”
- Entities, use cases, adapters, frameworks.
- “point inward”
- Every import goes towards the centre.
Why this picture fits: A castle: the keep never depends on the market stalls, but the stalls follow the keep's rules.
The story → the concept
| The castle | Pedal |
|---|---|
| The keep | Plan and Ride: the pricing rules |
| The inner wall | ReturnBike and the interfaces it defines |
| The bailey | Presenters and the row gateway |
| The market | The web framework and the database |
Watch it on an example
- The same ReturnBike produces JSON and a printed receipt.
- Changing the database changes only the gateway and the driver.
- The pricing rule can be read without seeing any framework.
The trap it saves you from
Building four rings for a service that has no rules worth protecting.
It still works when…
Inside a hexagon: clean is hexagonal with the inside named and divided.
In an interview or design review
- State the one rule: "Source dependencies point inward; inner rings define the interfaces outer rings implement."
- Relate it to hexagonal: same idea; clean names the rings and separates presenters from use cases.
- Size it to the problem: "For this CRUD admin service, layered is enough; the pricing engine gets the full rings."
10. Check yourself
Which ring holds Pedal's "30 free minutes for members" rule?
Where is the RideGateway interface defined?
A use case returns a JSON string. What's wrong?
How do clean and hexagonal differ?
11. Go deeper
| Resource | Why |
|---|---|
| The Clean Architecture (Robert C. Martin) | The original four rings and the dependency rule. |
| The Onion Architecture (Jeffrey Palermo) | The earlier, similar idea. |
| Architecture 07a: Inside a service | Hexagonal, onion and clean compared. |