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 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
| Rule | Why it exists | Broken when… |
|---|---|---|
| Each cell is complete | A 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 other | A failure can't spread sideways. | A request in cell-1 fetches data from cell-3. |
| The router stays thin and simple | It'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 cell | A bad release hits one cell before it can hit all. | A deploy goes to every cell at once. |
| Customers can move between cells | Hot or growing customers can be rebalanced. | A customer's data can only live where it was first created. |
4. How it goes wrong
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.
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 scale | Monolith or microservices with good rollouts |
| Customers constantly interact across cells | One shared system, with bulkheads inside |
| Hot, spiky load on shared data | Space-based |
| No central operator at all | Peer-to-peer |
8. Signs you've outgrown it
- Most new features need data from several cells.
- The router has become complicated and fails more than any cell.
- Cells have drifted into different versions and configurations that nobody can reason about.
9. Remember it and say it
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 ship | Pedal |
|---|---|
| Compartments | cell-1, cell-2, cell-3 |
| Passengers in each | London; Paris and Lisbon; Berlin |
| The deck plan by the door | The router: city → cell |
| A hole | The v2 pricing bug, contained to cell-2 |
Watch it on an example
- The bug reached Paris and Lisbon, not London or Berlin.
- A health check in the first cell caught it before wave 2.
- 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
- Say what a cell contains and what's shared: "Each cell has its app, database and queues; only the router and read-only reference data are global."
- Size cells by blast radius: "32 cells means a bad release in wave 1 touches about 3% of riders."
- Explain routing and moves: a mapping table in the router, and a migration process to move a customer's data between cells.
10. Check yourself
What makes a cell a cell?
With 20 cells and a cell-by-cell rollout, roughly how many customers does a bad release reach before it's stopped?
Cells share one payments database to save money. What's the problem?
Which part must be the most reliable in a cell-based design?
11. Go deeper
| Resource | Why |
|---|---|
| Reducing the scope of impact with cell-based architecture (AWS) | The provider's own guidance on cells, routers and sizing. |
| System design 28: Architecture styles | Cells next to monoliths, microservices and events. |
| System design 27: Deployment | Canary and wave-by-wave rollouts. |