InterviewKata
Architecture styles · 04 of 16 · Inside one application

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

A castle has rings of walls. The keep in the middle holds what matters most and changes least; the outer bailey has the market, the stables and the gate, which change all the time. People from the keep never need to know who runs the market stalls, but the stall-holders follow the keep's rules.

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)HoldsIn Pedal
1 · EntitiesBusiness rules that would exist without any softwareA Plan prices minutes; a Ride can be finished once
2 · Use casesWhat the application does, step by step, and the interfaces it needsReturnBike, with a RideGateway and a ReturnPresenter it defines
3 · Interface adaptersTranslation to and from outside formatsJSON and receipt presenters, a gateway that maps database rows
4 · Frameworks and driversThe web server, the database, UI frameworksWhatever 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

RuleWhy it existsBroken when…
Dependencies point inward onlyInner 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 needThe 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 plainNo outer type leaks inward.A database row object is passed into an entity method.
Entities don't know use casesThe core business rules are reusable by any application.Ride.finish() sends an email.

4. How it goes wrong

Rings as a folder templateFour folders created on day one for a CRUD app with no real rules: ceremony without benefit.
Entities that are only dataAll the rules live in use cases; the innermost ring protects nothing.
Leaking framework typesAnnotations, ORM base classes or request objects inside the inner rings.
One use case per endpoint, copiedDozens of near-identical interactors; group by what the business does, not by URL.

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 CRUDLayered
You want the inward rule with less structureHexagonal
Many customers or cities need different behaviourMicrokernel around a clean core
Teams must deploy separatelyClean services inside microservices

8. Signs you've outgrown it

9. Remember it and say it

Memory hook: "Rings point inward"The innermost code changes least and knows least; everything outside depends on 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 castlePedal
The keepPlan and Ride: the pricing rules
The inner wallReturnBike and the interfaces it defines
The baileyPresenters and the row gateway
The marketThe web framework and the database

Watch it on an example

  1. The same ReturnBike produces JSON and a printed receipt.
  2. Changing the database changes only the gateway and the driver.
  3. 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

10. Check yourself

Which ring holds Pedal's "30 free minutes for members" rule?

It's a business rule that would exist even without software.

Where is the RideGateway interface defined?

Inner rings own the interfaces; outer rings implement them.

A use case returns a JSON string. What's wrong?

Formatting belongs in the adapters ring.

How do clean and hexagonal differ?

They're variations on one idea.

11. Go deeper

ResourceWhy
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 serviceHexagonal, onion and clean compared.