Layered
The layered style is the default shape of most web applications: controllers on top, rules in the middle, storage at the bottom. This page builds Pedal's return flow in three layers, tests the rules with a fake repository, and shows the trade-off that matters most: technical changes stay in one layer, but feature changes cut through all of them.
1. In one picture
A layered application splits code by its technical job: a presentation layer that speaks HTTP or draws screens, a business layer that holds the rules, and a persistence layer that reads and writes data. Each layer calls only the layer directly below it. It is the default shape of most web frameworks, which is why it's often the first architecture people meet.
- Closed layers must be passed through in order: presentation never queries the database directly. That's what lets you change one layer without breaking the ones above it.
- An open layer may be skipped on purpose; for example, a shared utilities layer anyone can call. Mark which layers are open, or every shortcut becomes "open".
- A fourth layer often appears: an application (or service) layer between presentation and the domain rules, holding transactions and use-case orchestration.
2. Pedal built this way
Mia returns her bike after 42 minutes. The request goes down the layers and the answer comes back up.
Notice what each layer doesn't know: the controller doesn't know the pricing rule, the service doesn't know about HTTP or SQL, the repository doesn't know what a member is. That's why the service can be tested with a fake repository, as the code below does.
3. The rules
| Rule | Why it exists | Broken when… |
|---|---|---|
| Each layer calls only the layer below | Changes stay local: swapping the database touches only persistence. | A controller runs SQL "just for this one screen". |
| No calls upward | Lower layers stay reusable and testable without the upper ones. | A repository formats prices for display, or calls the controller. |
| Rules live in the business layer | One place to read and test what Pedal actually does. | Pricing logic sits in a SQL view or in the template that renders the receipt. |
| Layers are marked open or closed | Shortcuts are decisions, not accidents. | Half the code skips the service layer and nobody knows which half is allowed to. |
4. How it goes wrong
5. The code
Pedal in three layers wired by their constructors. The return request goes controller → service → repository; the dock count passes straight through (a small sinkhole); and the business layer is tested on its own with a fake repository.
from __future__ import annotations
from dataclasses import dataclass
# ---------- Persistence layer: the only code that knows how data is stored ----------
@dataclass
class Ride:
id: str
rider: str
plan: str
minutes: int = 0
price: int = 0
open: bool = True
class RideRepository:
def __init__(self) -> None:
self.rows: dict[str, Ride] = {}
def get(self, ride_id: str) -> Ride:
return self.rows[ride_id]
def save(self, ride: Ride) -> None:
self.rows[ride.id] = ride
class DockRepository:
def __init__(self, bikes: dict[str, int]) -> None:
self.bikes = bikes
def bikes_at(self, dock: str) -> int:
return self.bikes[dock]
# ---------- Business layer: Pedal's rules, and nothing about HTTP or storage ----------
class RideService:
def __init__(self, rides: RideRepository, docks: DockRepository) -> None:
self.rides, self.docks = rides, docks
@staticmethod
def price(plan: str, minutes: int) -> int:
return max(0, minutes - 30) * 10 if plan == "member" else 100 + minutes * 10
def finish(self, ride_id: str, minutes: int) -> Ride:
ride = self.rides.get(ride_id)
if not ride.open:
raise ValueError("ride already finished")
ride.minutes, ride.price, ride.open = minutes, self.price(ride.plan, minutes), False
self.rides.save(ride)
return ride
def bikes_at(self, dock: str) -> int: # no rule here: a "sinkhole" call
return self.docks.bikes_at(dock)
# ---------- Presentation layer: turns requests into calls and results into responses ----------
class RideController:
def __init__(self, service: RideService) -> None:
self.service = service
def handle(self, method: str, path: str, body: dict) -> str:
parts = path.strip("/").split("/")
if method == "POST" and parts[0] == "rides" and parts[2] == "return":
ride = self.service.finish(parts[1], body["minutes"])
return f'200 {{"price": "{ride.price // 100}.{ride.price % 100:02d}"}}'
if method == "GET" and parts[0] == "docks":
return f'200 {{"bikes": {self.service.bikes_at(parts[1])}}}'
return "404"
# A test double for the persistence layer, so the business layer is tested on its own.
class FakeRides(RideRepository):
def __init__(self, ride: Ride) -> None:
super().__init__()
self.rows[ride.id] = ride
if __name__ == "__main__":
# Wiring: each layer gets the one below it.
rides = RideRepository()
rides.save(Ride("R1", "Mia", "member"))
controller = RideController(RideService(rides, DockRepository({"D12": 1})))
print("POST /rides/R1/return ->", controller.handle("POST", "/rides/R1/return", {"minutes": 42}))
print("GET /docks/D12 ->", controller.handle("GET", "/docks/D12", {}), "(passed straight through)")
service = RideService(FakeRides(Ride("T1", "Leo", "payg")), DockRepository({}))
print("business layer alone: payg 15 min ->", service.finish("T1", 15).price)
// ---------- Persistence layer: the only code that knows how data is stored ----------
interface Ride { id: string; rider: string; plan: string; minutes: number; price: number; open: boolean; }
const newRide = (id: string, rider: string, plan: string): Ride => ({ id, rider, plan, minutes: 0, price: 0, open: true });
class RideRepository {
protected rows = new Map<string, Ride>();
get(id: string): Ride { const r = this.rows.get(id); if (!r) throw new Error("no ride " + id); return r; }
save(ride: Ride): void { this.rows.set(ride.id, ride); }
}
class DockRepository {
constructor(private bikes: Record<string, number>) {}
bikesAt(dock: string): number { return this.bikes[dock]; }
}
// ---------- Business layer: Pedal's rules, and nothing about HTTP or storage ----------
class RideService {
constructor(private rides: RideRepository, private docks: DockRepository) {}
static price(plan: string, minutes: number): number { return plan === "member" ? Math.max(0, minutes - 30) * 10 : 100 + minutes * 10; }
finish(id: string, minutes: number): Ride {
const ride = this.rides.get(id);
if (!ride.open) throw new Error("ride already finished");
ride.minutes = minutes; ride.price = RideService.price(ride.plan, minutes); ride.open = false;
this.rides.save(ride);
return ride;
}
bikesAt(dock: string): number { return this.docks.bikesAt(dock); } // no rule here: a "sinkhole" call
}
// ---------- Presentation layer: turns requests into calls and results into responses ----------
class RideController {
constructor(private service: RideService) {}
handle(method: string, path: string, body: { minutes?: number }): string {
const parts = path.replace(/^\/|\/$/g, "").split("/");
if (method === "POST" && parts[0] === "rides" && parts[2] === "return") {
const ride = this.service.finish(parts[1], body.minutes ?? 0);
return `200 {"price": "${Math.floor(ride.price / 100)}.${String(ride.price % 100).padStart(2, "0")}"}`;
}
if (method === "GET" && parts[0] === "docks") return `200 {"bikes": ${this.service.bikesAt(parts[1])}}`;
return "404";
}
}
// A test double for the persistence layer, so the business layer is tested on its own.
class FakeRides extends RideRepository { constructor(ride: Ride) { super(); this.rows.set(ride.id, ride); } }
// Wiring: each layer gets the one below it.
const rides = new RideRepository();
rides.save(newRide("R1", "Mia", "member"));
const controller = new RideController(new RideService(rides, new DockRepository({ D12: 1 })));
console.log("POST /rides/R1/return -> " + controller.handle("POST", "/rides/R1/return", { minutes: 42 }));
console.log("GET /docks/D12 -> " + controller.handle("GET", "/docks/D12", {}) + " (passed straight through)");
const service = new RideService(new FakeRides(newRide("T1", "Leo", "payg")), new DockRepository({}));
console.log("business layer alone: payg 15 min -> " + service.finish("T1", 15).price);import java.util.HashMap;
import java.util.Map;
// ---------- Persistence layer: the only code that knows how data is stored ----------
class Ride {
final String id, rider, plan;
int minutes = 0, price = 0;
boolean open = true;
Ride(String id, String rider, String plan) { this.id = id; this.rider = rider; this.plan = plan; }
}
class RideRepository {
protected final Map<String, Ride> rows = new HashMap<>();
Ride get(String id) { return rows.get(id); }
void save(Ride ride) { rows.put(ride.id, ride); }
}
class DockRepository {
private final Map<String, Integer> bikes;
DockRepository(Map<String, Integer> bikes) { this.bikes = bikes; }
int bikesAt(String dock) { return bikes.get(dock); }
}
// ---------- Business layer: Pedal's rules, and nothing about HTTP or storage ----------
class RideService {
private final RideRepository rides;
private final DockRepository docks;
RideService(RideRepository rides, DockRepository docks) { this.rides = rides; this.docks = docks; }
static int price(String plan, int minutes) { return plan.equals("member") ? Math.max(0, minutes - 30) * 10 : 100 + minutes * 10; }
Ride finish(String id, int minutes) {
Ride ride = rides.get(id);
if (!ride.open) throw new IllegalStateException("ride already finished");
ride.minutes = minutes; ride.price = price(ride.plan, minutes); ride.open = false;
rides.save(ride);
return ride;
}
int bikesAt(String dock) { return docks.bikesAt(dock); } // no rule here: a "sinkhole" call
}
// ---------- Presentation layer: turns requests into calls and results into responses ----------
class RideController {
private final RideService service;
RideController(RideService service) { this.service = service; }
String handle(String method, String path, Map<String, Integer> body) {
String[] parts = path.replaceAll("^/|/$", "").split("/");
if (method.equals("POST") && parts[0].equals("rides") && parts[2].equals("return")) {
Ride ride = service.finish(parts[1], body.get("minutes"));
return "200 {\"price\": \"" + (ride.price / 100) + "." + String.format("%02d", ride.price % 100) + "\"}";
}
if (method.equals("GET") && parts[0].equals("docks")) return "200 {\"bikes\": " + service.bikesAt(parts[1]) + "}";
return "404";
}
}
// A test double for the persistence layer, so the business layer is tested on its own.
class FakeRides extends RideRepository { FakeRides(Ride ride) { rows.put(ride.id, ride); } }
public class Main {
public static void main(String[] args) {
// Wiring: each layer gets the one below it.
RideRepository rides = new RideRepository();
rides.save(new Ride("R1", "Mia", "member"));
RideController controller = new RideController(new RideService(rides, new DockRepository(Map.of("D12", 1))));
System.out.println("POST /rides/R1/return -> " + controller.handle("POST", "/rides/R1/return", Map.of("minutes", 42)));
System.out.println("GET /docks/D12 -> " + controller.handle("GET", "/docks/D12", Map.of()) + " (passed straight through)");
RideService service = new RideService(new FakeRides(new Ride("T1", "Leo", "payg")), new DockRepository(Map.of()));
System.out.println("business layer alone: payg 15 min -> " + service.finish("T1", 15).price);
}
}Output (the same in all three languages)
POST /rides/R1/return -> 200 {"price": "1.20"}
GET /docks/D12 -> 200 {"bikes": 1} (passed straight through)
business layer alone: payg 15 min -> 250
6. Try it
Who has to change it?
Layers split work by technical job, so teams often do too. Compare that with teams organised by business area (each owning its own slice, layered inside). Pick a way to organise and read how many teams each Pedal change needs.
7. What it costs, and when to pick another style
What you get
- Familiar: almost every developer and framework knows it.
- Separation of technical concerns: screens, rules and storage can each be changed and tested apart.
- Easy to start: a good default for small apps and forms over data.
- Technology swaps stay local: a new database touches one layer.
What you pay
- Feature changes cut across: most new features touch every layer.
- Sinkhole overhead: pass-through code for simple reads.
- Still one deployable: everything the monolith page says about release trains applies.
- Easy to erode: without checks, layers leak within months.
| If… | Consider instead |
|---|---|
| Business rules are complex and long-lived | Hexagonal or clean: rules at the centre, not the middle |
| Teams are organised by business area | Modular monolith with layers inside each module |
| Most requests are simple reads | Open the business layer for reads, or use a separate read path |
| Parts need different release cadences | Service-based |
8. Signs you've outgrown it
- Most features need changes in every layer, and those changes are made by different people who must coordinate.
- Most requests pass straight through the business layer.
- Rules are scattered across controllers, services and stored procedures.
- The persistence layer has grown a model of everything, shared by every feature.
9. Remember it and say it
How this hook works: what “Counter, back office, vault” means
What the slogan means, phrase by phrase
- “Counter”
- Presentation: HTTP, screens, formats.
- “back office”
- Business: Pedal's rules.
- “vault”
- Persistence: storage, reached only through the layer above.
Why this picture fits: A bank branch: customers never walk into the vault; the counter never decides a loan.
The story → the concept
| The bank | Pedal |
|---|---|
| The counter | RideController |
| The back office | RideService and its pricing rule |
| The vault | RideRepository and the database |
| Hand-off slips | Method calls with plain values |
Watch it on an example
- Mia's return goes counter → back office → vault and back.
- The pricing rule is tested with a fake vault.
- "How many bikes at Dock 12?" passes straight through: a small sinkhole, which is fine.
The trap it saves you from
Letting the counter open the vault "just this once", until nobody knows which layer does what.
It still works when…
Inside other styles: each microservice or each monolith module is often layered internally.
In an interview or design review
- Say which layers are closed: "Presentation, business and persistence, all closed; a shared utilities layer is open."
- Name its main weakness: layers split by technical job, so a feature touches all of them; for large teams, split by business area first and layer inside.
- Mention the sinkhole and what you'd do about it for read-heavy paths.
- Enforce it: architecture tests (ArchUnit, import-linter, dependency-cruiser) fail the build when a layer is skipped.
10. Check yourself
A controller runs a SQL query directly to save time. What's broken?
Adding helmet rental to rides touches screens, rules and tables. Why is that typical of layered code?
Most requests pass through the business layer without any logic. What's this called?
How do you test Pedal's pricing rule without a database?
11. Go deeper
| Resource | Why |
|---|---|
| Architecture 11: Layered and modular monolith | How an architect weighs this style. |
| LLD 03e: Dependency inversion | Constructor wiring between layers, and why it makes them testable. |
| ArchUnit and dependency-cruiser | Checking layer rules in Java and JavaScript builds. |