InterviewKata
Architecture styles · 02 of 16 · Inside one application

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.

See the patternThe patterns that usually sit inside a layered app, built step by step: 25 Repository and unit of work · 08 Facade.

1. In one picture

A bank branch has a counter, back offices and a vault. Customers only talk to the counter; the counter staff pass work to the back office; only the back office opens the vault. Each level can be reorganised without the others noticing, as long as the hand-offs stay the same.

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.

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

RuleWhy it existsBroken when…
Each layer calls only the layer belowChanges stay local: swapping the database touches only persistence.A controller runs SQL "just for this one screen".
No calls upwardLower layers stay reusable and testable without the upper ones.A repository formats prices for display, or calls the controller.
Rules live in the business layerOne 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 closedShortcuts are decisions, not accidents.Half the code skips the service layer and nobody knows which half is allowed to.

4. How it goes wrong

The sinkholeMost requests pass through every layer with no logic added. A little is normal; if it's most of them, the layers cost more than they give.
Rules leaking up or downPricing in the controller, validation in stored procedures: the business layer becomes a thin wrapper and nobody knows where the rules are.
Layers mistaken for modulesThree big layers shared by every feature: a change to rides touches every layer, and teams split by layer must all coordinate.
Anaemic modelsObjects that are just fields, with every rule in services that pass them around; usually a sign the business layer was skipped in thought, not in code.

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-livedHexagonal or clean: rules at the centre, not the middle
Teams are organised by business areaModular monolith with layers inside each module
Most requests are simple readsOpen the business layer for reads, or use a separate read path
Parts need different release cadencesService-based

8. Signs you've outgrown it

9. Remember it and say it

Memory hook: "Counter, back office, vault"Each layer has one technical job and talks only to the layer below 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 bankPedal
The counterRideController
The back officeRideService and its pricing rule
The vaultRideRepository and the database
Hand-off slipsMethod calls with plain values

Watch it on an example

  1. Mia's return goes counter → back office → vault and back.
  2. The pricing rule is tested with a fake vault.
  3. "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

10. Check yourself

A controller runs a SQL query directly to save time. What's broken?

Now a schema change can break a screen, and rules can be bypassed.

Adding helmet rental to rides touches screens, rules and tables. Why is that typical of layered code?

Domain partitioning keeps a feature in one module instead.

Most requests pass through the business layer without any logic. What's this called?

Some is normal; mostly-sinkhole means the layering costs more than it gives.

How do you test Pedal's pricing rule without a database?

Layers communicate through interfaces you can replace in tests.

11. Go deeper

ResourceWhy
Architecture 11: Layered and modular monolithHow an architect weighs this style.
LLD 03e: Dependency inversionConstructor wiring between layers, and why it makes them testable.
ArchUnit and dependency-cruiserChecking layer rules in Java and JavaScript builds.