InterviewKata
Architecture styles · 15 of 16 · Scale and decentralisation

Cell-based

Cell-based architecture runs many complete, independent copies of the whole system, each serving a slice of customers, so that no failure or bad release can reach everyone. This page runs Pedal's cities in three cells, catches a pricing bug in the first cell of a rollout, moves a city between cells, and weighs blast radius against overhead.

1. In one picture

A ship's hull is divided into watertight compartments. A hole floods one compartment, not the ship, and the ship stays afloat. Each compartment is a full section of hull, not a special part, and the bulkheads between them have no doors that matter when water comes in.

A cell-based architecture runs many complete, independent copies of the whole system, each called a cell: its own application servers, databases, queues and caches. Each customer (or city, or tenant) lives in exactly one cell. A thin router in front sends every request to the right cell, and cells never call each other. A bug, a bad release, a runaway customer or a broken database stays inside one cell: the blast radius is one cell's share of customers.

This style is our addition: it isn't in the course this track was checked against. Large cloud and SaaS providers use it widely, and AWS describes it in its guidance on reducing the scope of impact. It combines with other styles: each cell can be a monolith or a set of microservices.

2. Pedal built this way

Four cities across three cells. A new pricing release has a bug. Follow it through a cell-by-cell rollout.

3. The rules

RuleWhy it existsBroken when…
Each cell is completeA cell can serve its customers with everything else down.Cells share one database or one payments service "to save money".
Cells don't call each otherA failure can't spread sideways.A request in cell-1 fetches data from cell-3.
The router stays thin and simpleIt's the one shared part, so it must be the most reliable.The router grows business logic and becomes a new single point of failure.
Changes roll out cell by cellA bad release hits one cell before it can hit all.A deploy goes to every cell at once.
Customers can move between cellsHot or growing customers can be rebalanced.A customer's data can only live where it was first created.

4. How it goes wrong

Shared dependencies between cellsA shared database or service quietly turns every cell into one failure domain again.
A fat routerLogic in the routing layer makes it the riskiest component in the system.
Cells too largeTwo cells halve the blast radius; the benefit grows with more, smaller cells (at more operating cost).
No plan for global dataData every cell needs (the list of cities, prices) must be replicated read-only into each cell, not shared live.

5. The code

Three cells, each a complete Pedal with its own data, behind a router that maps cities to cells. A buggy pricing release goes to cell-2 first, fails its health check and is rolled back; then Lisbon is moved to cell-3 by changing one router entry.

from __future__ import annotations


def price_v1(plan: str, minutes: int) -> int:
    return max(0, minutes - 30) * 10 if plan == "member" else 100 + minutes * 10


def price_v2(plan: str, minutes: int) -> int:       # a release with a bug: pence treated as tenths
    return price_v1(plan, minutes) * 10


class Cell:
    """A complete copy of Pedal (app, database, queues) serving only its own cities."""

    def __init__(self, name: str) -> None:
        self.name, self.version, self.price = name, "v1", price_v1
        self.rides: list[tuple[str, int]] = []          # this cell's own data

    def quote(self, city: str, plan: str, minutes: int) -> int:
        p = self.price(plan, minutes)
        self.rides.append((city, p))
        return p

    def healthy(self) -> bool:                          # a canary check: a known ride must price correctly
        return self.price("member", 42) == 120


class Router:                                           # thin: it only maps a city to its cell
    def __init__(self, cells: dict[str, Cell], home: dict[str, str]) -> None:
        self.cells, self.home = cells, home

    def quote(self, city: str, plan: str, minutes: int) -> int:
        return self.cells[self.home[city]].quote(city, plan, minutes)


TRIPS = [("London", "member", 42), ("Paris", "payg", 15), ("Lisbon", "payg", 20), ("Berlin", "member", 42)]


def money(p: int) -> str:
    return f"{p // 100}.{p % 100:02d}"


def run(router: Router) -> str:
    return " | ".join(f"{c} {money(router.quote(c, plan, m))}" for c, plan, m in TRIPS)


if __name__ == "__main__":
    cells = {n: Cell(n) for n in ("cell-1", "cell-2", "cell-3")}
    router = Router(cells, {"London": "cell-1", "Paris": "cell-2", "Lisbon": "cell-2", "Berlin": "cell-3"})
    print("all cells on v1:", run(router))
    cells["cell-2"].version, cells["cell-2"].price = "v2", price_v2      # wave 1: one cell only
    print("v2 in cell-2:", run(router))
    if not cells["cell-2"].healthy():
        cells["cell-2"].version, cells["cell-2"].price = "v1", price_v1
        affected = [c for c, home in router.home.items() if home == "cell-2"]
        print(f"cell-2 failed its health check and rolled back; affected while live: {len(affected)} of 4 cities ({', '.join(affected)})")
    router.home["Lisbon"] = "cell-3"                                   # rebalance: move a city between cells
    print("after moving Lisbon to cell-3:", run(router))
    print("rides per cell:", {n: len(c.rides) for n, c in cells.items()})
type Pricer = (plan: string, minutes: number) => number;
const priceV1: Pricer = (plan, minutes) => (plan === "member" ? Math.max(0, minutes - 30) * 10 : 100 + minutes * 10);
const priceV2: Pricer = (plan, minutes) => priceV1(plan, minutes) * 10; // a release with a bug: pence treated as tenths

// A complete copy of Pedal (app, database, queues) serving only its own cities.
class Cell {
  version = "v1"; price: Pricer = priceV1;
  rides: [string, number][] = []; // this cell's own data
  constructor(readonly name: string) {}
  quote(city: string, plan: string, minutes: number): number { const p = this.price(plan, minutes); this.rides.push([city, p]); return p; }
  healthy(): boolean { return this.price("member", 42) === 120; } // a canary check: a known ride must price correctly
}

class Router { // thin: it only maps a city to its cell
  constructor(private cells: Record<string, Cell>, readonly home: Record<string, string>) {}
  quote(city: string, plan: string, minutes: number): number { return this.cells[this.home[city]].quote(city, plan, minutes); }
}

const TRIPS: [string, string, number][] = [["London", "member", 42], ["Paris", "payg", 15], ["Lisbon", "payg", 20], ["Berlin", "member", 42]];
const money = (p: number): string => `${Math.floor(p / 100)}.${String(p % 100).padStart(2, "0")}`;
const run = (r: Router): string => TRIPS.map(([c, plan, m]) => `${c} ${money(r.quote(c, plan, m))}`).join(" | ");

const cells: Record<string, Cell> = { "cell-1": new Cell("cell-1"), "cell-2": new Cell("cell-2"), "cell-3": new Cell("cell-3") };
const router = new Router(cells, { London: "cell-1", Paris: "cell-2", Lisbon: "cell-2", Berlin: "cell-3" });
console.log("all cells on v1: " + run(router));
cells["cell-2"].version = "v2"; cells["cell-2"].price = priceV2; // wave 1: one cell only
console.log("v2 in cell-2: " + run(router));
if (!cells["cell-2"].healthy()) {
  cells["cell-2"].version = "v1"; cells["cell-2"].price = priceV1;
  const affected = Object.entries(router.home).filter(([, h]) => h === "cell-2").map(([c]) => c);
  console.log(`cell-2 failed its health check and rolled back; affected while live: ${affected.length} of 4 cities (${affected.join(", ")})`);
}
router.home.Lisbon = "cell-3"; // rebalance: move a city between cells
console.log("after moving Lisbon to cell-3: " + run(router));
console.log("rides per cell: {" + Object.entries(cells).map(([n, c]) => `'${n}': ${c.rides.length}`).join(", ") + "}");
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.function.BiFunction;
import java.util.stream.Collectors;

// A complete copy of Pedal (app, database, queues) serving only its own cities.
class Cell {
    final String name; String version = "v1"; BiFunction<String, Integer, Integer> price = Main::priceV1;
    final List<String> rides = new ArrayList<>(); // this cell's own data
    Cell(String name) { this.name = name; }
    int quote(String city, String plan, int minutes) { int p = price.apply(plan, minutes); rides.add(city); return p; }
    boolean healthy() { return price.apply("member", 42) == 120; } // a canary check: a known ride must price correctly
}

class Router { // thin: it only maps a city to its cell
    private final Map<String, Cell> cells; final Map<String, String> home;
    Router(Map<String, Cell> cells, Map<String, String> home) { this.cells = cells; this.home = home; }
    int quote(String city, String plan, int minutes) { return cells.get(home.get(city)).quote(city, plan, minutes); }
}

public class Main {
    static int priceV1(String plan, int minutes) { return plan.equals("member") ? Math.max(0, minutes - 30) * 10 : 100 + minutes * 10; }
    static int priceV2(String plan, int minutes) { return priceV1(plan, minutes) * 10; } // a release with a bug: pence treated as tenths
    static String money(int p) { return (p / 100) + "." + String.format("%02d", p % 100); }
    static final Object[][] TRIPS = {{"London", "member", 42}, {"Paris", "payg", 15}, {"Lisbon", "payg", 20}, {"Berlin", "member", 42}};
    static String run(Router r) {
        List<String> out = new ArrayList<>();
        for (Object[] t : TRIPS) out.add(t[0] + " " + money(r.quote((String) t[0], (String) t[1], (Integer) t[2])));
        return String.join(" | ", out);
    }
    public static void main(String[] args) {
        Map<String, Cell> cells = new LinkedHashMap<>();
        for (String n : List.of("cell-1", "cell-2", "cell-3")) cells.put(n, new Cell(n));
        Map<String, String> home = new LinkedHashMap<>();
        home.put("London", "cell-1"); home.put("Paris", "cell-2"); home.put("Lisbon", "cell-2"); home.put("Berlin", "cell-3");
        Router router = new Router(cells, home);
        System.out.println("all cells on v1: " + run(router));
        cells.get("cell-2").version = "v2"; cells.get("cell-2").price = Main::priceV2; // wave 1: one cell only
        System.out.println("v2 in cell-2: " + run(router));
        if (!cells.get("cell-2").healthy()) {
            cells.get("cell-2").version = "v1"; cells.get("cell-2").price = Main::priceV1;
            List<String> affected = home.entrySet().stream().filter(e -> e.getValue().equals("cell-2")).map(Map.Entry::getKey).collect(Collectors.toList());
            System.out.println("cell-2 failed its health check and rolled back; affected while live: " + affected.size() + " of 4 cities (" + String.join(", ", affected) + ")");
        }
        router.home.put("Lisbon", "cell-3"); // rebalance: move a city between cells
        System.out.println("after moving Lisbon to cell-3: " + run(router));
        System.out.println("rides per cell: {" + cells.entrySet().stream().map(e -> "'" + e.getKey() + "': " + e.getValue().rides.size()).collect(Collectors.joining(", ")) + "}");
    }
}

Output (the same in all three languages)

all cells on v1: London 1.20 | Paris 2.50 | Lisbon 3.00 | Berlin 1.20
v2 in cell-2: London 1.20 | Paris 25.00 | Lisbon 30.00 | Berlin 1.20
cell-2 failed its health check and rolled back; affected while live: 2 of 4 cities (Paris, Lisbon)
after moving Lisbon to cell-3: London 1.20 | Paris 2.50 | Lisbon 3.00 | Berlin 1.20
rides per cell: {'cell-1': 3, 'cell-2': 5, 'cell-3': 4}

6. Try it

Blast radius

Pedal has a million riders. Choose how many cells and how releases are rolled out, then ship a bad release.

Cells
Rollout

Assumes the bad release is caught by its health checks in the first wave when rolled out one cell first. The overhead estimate assumes each cell keeps a fixed minimum of capacity, about 5% of today's total.

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

What you get

  • Small blast radius: a failure reaches one cell's customers, not all of them.
  • Safer releases: roll out cell by cell, stop at the first sign of trouble.
  • Predictable scaling: add cells instead of growing one huge system past what's been tested.
  • Noisy neighbours contained: one heavy customer only crowds its own cell.

What you pay

  • More infrastructure: every cell repeats every component, including fixed minimums.
  • A routing layer: the one shared part, which must be extremely reliable.
  • Cross-cell features are hard: anything spanning customers in different cells needs separate design.
  • Operations at scale: deploys, monitoring and migrations across many cells.
If…Consider instead
A single region and modest scaleMonolith or microservices with good rollouts
Customers constantly interact across cellsOne shared system, with bulkheads inside
Hot, spiky load on shared dataSpace-based
No central operator at allPeer-to-peer

8. Signs you've outgrown it

9. Remember it and say it

Memory hook: "Watertight compartments"Many complete copies of the system, each serving a slice of customers, so a failure floods one compartment, not the ship.
How this hook works: what “Watertight compartments” means

What the slogan means, phrase by phrase

“Watertight”
Cells share nothing and never call each other.
“compartments”
Each is a complete copy serving its own customers.

Why this picture fits: A ship's hull: a hole floods one compartment and the ship stays afloat.

The story → the concept

The shipPedal
Compartmentscell-1, cell-2, cell-3
Passengers in eachLondon; Paris and Lisbon; Berlin
The deck plan by the doorThe router: city → cell
A holeThe v2 pricing bug, contained to cell-2

Watch it on an example

  1. The bug reached Paris and Lisbon, not London or Berlin.
  2. A health check in the first cell caught it before wave 2.
  3. Lisbon moved to cell-3 by changing one router entry.

The trap it saves you from

Sharing a database or service between cells, so they all sink together.

It still works when…

Around other styles: each cell can be a monolith, services or serverless.

In an interview or design review

10. Check yourself

What makes a cell a cell?

Cells share nothing and never call each other.

With 20 cells and a cell-by-cell rollout, roughly how many customers does a bad release reach before it's stopped?

One cell's share, if the first wave's health checks catch it.

Cells share one payments database to save money. What's the problem?

Shared dependencies merge the failure domains back together.

Which part must be the most reliable in a cell-based design?

It's the one component every request passes through.

11. Go deeper

ResourceWhy
Reducing the scope of impact with cell-based architecture (AWS)The provider's own guidance on cells, routers and sizing.
System design 28: Architecture stylesCells next to monoliths, microservices and events.
System design 27: DeploymentCanary and wave-by-wave rollouts.