InterviewKata
Architecture styles · 06 of 16 · Across machines

Client-server and tiers

Client-server is the outer shape of almost every system, and tiers are how its server side is split across machines. This page builds Pedal in three tiers with stateless web servers and a shared session store, and measures how quickly chatty calls between tiers add up when the tiers drift apart.

1. In one picture

At a library, readers ask at the desk and librarians fetch from the stacks. Readers never wander the stacks; the desk can have many staff working in shifts; the stacks are kept in one guarded place. Any librarian can serve any reader, because the reader's loan record lives in the library's system, not in one librarian's head.

Client-server is the oldest and most common shape: clients ask, a server owns the data and the rules and answers. Almost every other style on this track sits inside it. Tiers describe how the server side is split across machines: unlike layers, which split code inside one program, tiers are physically separate and talk over a network.

ShapeMachinesWhere it still fits
Two-tierClient talks straight to the databaseInternal desktop tools on a trusted network; mostly retired
Three-tierClient · application servers · databaseThe default for web and mobile backends
Four-tier and beyondClient · web or API tier · application tier · data tier (plus caches, search…)Larger systems that scale and secure each tier separately

2. Pedal built this way

Mia's three requests land on different web servers. Because the web tier keeps no state of its own, it doesn't matter which one answers.

The rule that makes this work: servers in a tier are stateless. Anything that must survive between requests (sessions, carts, ride state) lives in a shared store or the data tier. Then a load balancer can send any request anywhere, a crashed server loses nothing, and scaling a tier means adding identical copies (System design module 08).

3. The rules

RuleWhy it existsBroken when…
Clients never touch the data tierThe server enforces the rules and protects the data.The phone app has database credentials compiled into it.
Servers in a tier are statelessAny copy can serve any request; scaling is adding copies.A web server keeps sessions in its own memory, so users are logged out when it restarts.
Each tier talks only to the nextEach tier can be secured, scaled and replaced on its own.The web tier queries the database "for speed".
Calls between tiers are few and coarseEvery tier hop is a network round trip that can be slow or fail.One page makes 40 small queries from the app tier to the database.

4. How it goes wrong

Sticky stateSessions in a server's memory force the load balancer to pin users, and a restart logs everyone out.
Chatty tiersMany small calls between tiers, each a network round trip; fine in one zone, painful across zones, fatal across regions.
Two-tier over the internetClients talking directly to a database: credentials on devices, no place for rules, a round trip per query.
No timeouts between tiersA slow data tier ties up every application thread waiting for it.

5. The code

Pedal's three tiers in one program: two interchangeable web servers behind round-robin balancing, a shared session store, an application tier with the rules, and a data tier. A request with an unknown session is refused, whichever server gets it.

from __future__ import annotations
from itertools import cycle

# Each class below would run on its own machines: a tier. Calls between them would be network calls.


class DataTier:                                   # the database servers
    def __init__(self) -> None:
        self.bikes = {"D12": ["B7", "B9"]}
        self.rides: dict[str, dict] = {}

    def take_bike(self, dock: str) -> str:
        return self.bikes[dock].pop(0)

    def save_ride(self, ride_id: str, ride: dict) -> None:
        self.rides[ride_id] = ride


class AppTier:                                    # business rules, behind the web tier
    def __init__(self, data: DataTier) -> None:
        self.data = data

    def unlock(self, rider: str, dock: str) -> str:
        bike = self.data.take_bike(dock)
        ride_id = f"R{len(self.data.rides) + 1}"
        self.data.save_ride(ride_id, {"rider": rider, "bike": bike})
        return f"{ride_id} on {bike}"

    def price(self, plan: str, minutes: int) -> str:
        p = max(0, minutes - 30) * 10 if plan == "member" else 100 + minutes * 10
        return f"£{p // 100}.{p % 100:02d}"


class SessionStore:                               # shared by every web server (e.g. Redis)
    def __init__(self) -> None:
        self.sessions: dict[str, dict] = {}


class WebServer:                                  # presentation tier: stateless, many identical copies
    def __init__(self, name: str, app: AppTier, sessions: SessionStore) -> None:
        self.name, self.app, self.sessions = name, app, sessions

    def handle(self, request: dict) -> str:
        if request["path"] == "/login":
            token = f"s{len(self.sessions.sessions) + 1}"
            self.sessions.sessions[token] = {"rider": request["rider"], "plan": request["plan"]}
            return f"{self.name}: logged in {request['rider']}, session {token} stored in the shared store"
        session = self.sessions.sessions.get(request["token"])
        if session is None:
            return f"{self.name}: 401 no session"
        if request["path"] == "/unlock":
            return f"{self.name}: session {request['token']} found, unlocked {self.app.unlock(session['rider'], request['dock'])}"
        return f"{self.name}: session {request['token']} found, return after {request['minutes']} min costs {self.app.price(session['plan'], request['minutes'])}"


if __name__ == "__main__":
    data = DataTier()
    app = AppTier(data)
    sessions = SessionStore()
    balancer = cycle([WebServer("web-1", app, sessions), WebServer("web-2", app, sessions)])   # round robin
    requests = [{"path": "/login", "rider": "Mia", "plan": "member"},
                {"path": "/unlock", "token": "s1", "dock": "D12"},
                {"path": "/return", "token": "s1", "minutes": 42},
                {"path": "/unlock", "token": "s9", "dock": "D12"}]
    for r in requests:
        print(next(balancer).handle(r))
    print("tiers: client -> web (stateless) -> app -> data")
// Each class below would run on its own machines: a tier. Calls between them would be network calls.

class DataTier { // the database servers
  bikes: Record<string, string[]> = { D12: ["B7", "B9"] };
  rides = new Map<string, { rider: string; bike: string }>();
  takeBike(dock: string): string { return this.bikes[dock].shift() as string; }
  saveRide(id: string, ride: { rider: string; bike: string }): void { this.rides.set(id, ride); }
}

class AppTier { // business rules, behind the web tier
  constructor(private data: DataTier) {}
  unlock(rider: string, dock: string): string {
    const bike = this.data.takeBike(dock);
    const id = `R${this.data.rides.size + 1}`;
    this.data.saveRide(id, { rider, bike });
    return `${id} on ${bike}`;
  }
  price(plan: string, minutes: number): string {
    const p = plan === "member" ? Math.max(0, minutes - 30) * 10 : 100 + minutes * 10;
    return `£${Math.floor(p / 100)}.${String(p % 100).padStart(2, "0")}`;
  }
}

class SessionStore { sessions = new Map<string, { rider: string; plan: string }>(); } // shared by every web server (e.g. Redis)

interface Req { path: string; rider?: string; plan?: string; token?: string; dock?: string; minutes?: number; }

class WebServer { // presentation tier: stateless, many identical copies
  constructor(private name: string, private app: AppTier, private store: SessionStore) {}
  handle(r: Req): string {
    if (r.path === "/login") {
      const token = `s${this.store.sessions.size + 1}`;
      this.store.sessions.set(token, { rider: r.rider ?? "", plan: r.plan ?? "" });
      return `${this.name}: logged in ${r.rider}, session ${token} stored in the shared store`;
    }
    const s = this.store.sessions.get(r.token ?? "");
    if (!s) return `${this.name}: 401 no session`;
    if (r.path === "/unlock") return `${this.name}: session ${r.token} found, unlocked ${this.app.unlock(s.rider, r.dock ?? "")}`;
    return `${this.name}: session ${r.token} found, return after ${r.minutes} min costs ${this.app.price(s.plan, r.minutes ?? 0)}`;
  }
}

const data = new DataTier(), app = new AppTier(data), sessions = new SessionStore();
const servers = [new WebServer("web-1", app, sessions), new WebServer("web-2", app, sessions)];
const requests: Req[] = [{ path: "/login", rider: "Mia", plan: "member" }, { path: "/unlock", token: "s1", dock: "D12" },
  { path: "/return", token: "s1", minutes: 42 }, { path: "/unlock", token: "s9", dock: "D12" }];
requests.forEach((r, i) => console.log(servers[i % servers.length].handle(r))); // round robin
console.log("tiers: client -> web (stateless) -> app -> data");
import java.util.HashMap;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.ArrayList;
import java.util.Map;

// Each class below would run on its own machines: a tier. Calls between them would be network calls.
class DataTier { // the database servers
    final Map<String, List<String>> bikes = new HashMap<>(Map.of("D12", new ArrayList<>(List.of("B7", "B9"))));
    final Map<String, String[]> rides = new LinkedHashMap<>();
    String takeBike(String dock) { return bikes.get(dock).remove(0); }
    void saveRide(String id, String rider, String bike) { rides.put(id, new String[]{rider, bike}); }
}

class AppTier { // business rules, behind the web tier
    private final DataTier data;
    AppTier(DataTier data) { this.data = data; }
    String unlock(String rider, String dock) {
        String bike = data.takeBike(dock);
        String id = "R" + (data.rides.size() + 1);
        data.saveRide(id, rider, bike);
        return id + " on " + bike;
    }
    String price(String plan, int minutes) {
        int p = plan.equals("member") ? Math.max(0, minutes - 30) * 10 : 100 + minutes * 10;
        return "£" + (p / 100) + "." + String.format("%02d", p % 100);
    }
}

class SessionStore { final Map<String, String[]> sessions = new HashMap<>(); } // shared by every web server (e.g. Redis)

class WebServer { // presentation tier: stateless, many identical copies
    private final String name; private final AppTier app; private final SessionStore store;
    WebServer(String name, AppTier app, SessionStore store) { this.name = name; this.app = app; this.store = store; }
    String handle(Map<String, String> r) {
        if (r.get("path").equals("/login")) {
            String token = "s" + (store.sessions.size() + 1);
            store.sessions.put(token, new String[]{r.get("rider"), r.get("plan")});
            return name + ": logged in " + r.get("rider") + ", session " + token + " stored in the shared store";
        }
        String[] s = store.sessions.get(r.get("token"));
        if (s == null) return name + ": 401 no session";
        if (r.get("path").equals("/unlock")) return name + ": session " + r.get("token") + " found, unlocked " + app.unlock(s[0], r.get("dock"));
        return name + ": session " + r.get("token") + " found, return after " + r.get("minutes") + " min costs " + app.price(s[1], Integer.parseInt(r.get("minutes")));
    }
}

public class Main {
    public static void main(String[] args) {
        DataTier data = new DataTier();
        AppTier app = new AppTier(data);
        SessionStore sessions = new SessionStore();
        List<WebServer> servers = List.of(new WebServer("web-1", app, sessions), new WebServer("web-2", app, sessions));
        List<Map<String, String>> requests = List.of(Map.of("path", "/login", "rider", "Mia", "plan", "member"), Map.of("path", "/unlock", "token", "s1", "dock", "D12"),
            Map.of("path", "/return", "token", "s1", "minutes", "42"), Map.of("path", "/unlock", "token", "s9", "dock", "D12"));
        for (int i = 0; i < requests.size(); i++) System.out.println(servers.get(i % servers.size()).handle(requests.get(i))); // round robin
        System.out.println("tiers: client -> web (stateless) -> app -> data");
    }
}

Output (the same in all three languages)

web-1: logged in Mia, session s1 stored in the shared store
web-2: session s1 found, unlocked R1 on B7
web-1: session s1 found, return after 42 min costs £1.20
web-2: 401 no session
tiers: client -> web (stateless) -> app -> data

6. Try it

Chatty tiers

One Pedal screen needs some database queries. Choose the shape, how many queries, and where the tiers sit. A phone's round trip to the servers is about 40 ms.

Shape
Queries per screen
Tiers placed

Round trips: phone to servers about 40 ms; between tiers about 0.5 ms in one zone, 1.5 ms across zones, 70 ms across regions; 5 ms of work in the application. Rough, but the right orders of magnitude.

7. What it costs, and when to pick another style

What you get

  • Central control: the server owns data, rules and security.
  • Independent scaling: each tier gets as many copies as it needs.
  • Thin, replaceable clients: phone, web and kiosk share one backend.
  • Clear security zones: only the web tier faces the internet; the database faces only the app tier.

What you pay

  • Network hops: every tier adds latency and a way to fail.
  • A central bottleneck: the data tier is usually the hardest to scale.
  • The server is a single point of control: if it's down, every client is.
  • More infrastructure: load balancers, sessions stores, firewalls per tier.
If…Consider instead
Clients must keep working offline or there's no trusted centrePeer-to-peer or local-first
The server side itself needs splitting by business areaService-based or microservices
Traffic is spiky and smallServerless for the application tier
Callers shouldn't know which server handles themBroker

8. Signs you've outgrown it

9. Remember it and say it

Memory hook: "Ask at the desk"Clients ask, servers answer; the server side is split into tiers on separate machines, each stateless and talking only to the next.
How this hook works: what “Ask at the desk” means

What the slogan means, phrase by phrase

“Ask”
Clients send requests; they never fetch data themselves.
“at the desk”
The server owns the data and rules; any desk clerk can help any reader.

Why this picture fits: A library: readers ask at the desk, librarians fetch from the guarded stacks, and loan records live in the system, not in a librarian's head.

The story → the concept

The libraryPedal
ReadersPhones and the web app
The desk staffStateless web servers
The catalogue systemThe shared session store
The stacksThe data tier

Watch it on an example

  1. Mia's three requests hit two web servers; both know her session.
  2. A request with an unknown session gets a 401, wherever it lands.
  3. The phone never sees the database.

The trap it saves you from

Keeping sessions in one server's memory, then pinning users to it.

It still works when…

As the outside of every other style: microservices and serverless are client-server too.

In an interview or design review

10. Check yourself

What's the difference between a layer and a tier?

Tiers add network hops; layers don't.

Why must web servers in a tier be stateless?

State lives in a shared store or the data tier.

A screen makes 30 queries and the application and database tiers move to different regions. What happens?

Keep chatty tiers close together, or make calls coarser.

Why are two-tier designs rare for mobile apps?

A server tier protects data and holds the rules.

11. Go deeper

ResourceWhy
System design 08: Load balancing and scaling outStateless tiers behind load balancers, in depth.
System design 02: Latency and the numbers to knowWhere the round-trip numbers in the lab come from.
Architecture 10: Monolithic vs distributedWhat changes when parts move to separate machines.