Microservices
Microservices trade the monolith's simplicity for independence: many teams releasing and scaling their own parts. This page builds Pedal as three services with their own data, keeps billing off the rider's path with an event, survives billing being down, and shows in a lab why long synchronous call chains are the style's quiet killer.
1. In one picture
Microservices split a system into small services, each built around one business capability, owned by one team, deployed on its own and owning its own data: no other service reads its database. Services talk through APIs (synchronous calls when the caller needs an answer now) and events (asynchronous messages when it doesn't).
The point is organisational as much as technical: many teams can change, release and scale their parts independently. The price is that everything a monolith gave for free (a function call, a transaction, a stack trace) becomes a network call, a saga and a distributed trace. See Architecture module 16 for the architect's view.
2. Pedal built this way
An unlock needs an answer now, so Rides calls Docks and waits. Billing doesn't need to answer the rider at all, so Rides publishes an event and moves on. Follow a return while Billing is down.
Two habits make this safe: idempotent consumers (Billing remembers which events it has handled, so a redelivery does nothing) and events written reliably (with an outbox, so a ride can't be saved without its event: System design module 22).
3. The rules
| Rule | Why it exists | Broken when… |
|---|---|---|
| One service, one team, one business capability | A team can change its service without negotiating with others. | One feature needs coordinated releases of four services. |
| Each service owns its data | Schema changes stay private; services can pick the right store. | Billing queries the rides database directly. |
| Independent deployment | Releases don't queue behind each other. | Services must be released in a fixed order on the same day. |
| Prefer events when nobody needs to wait | Fewer synchronous chains, fewer cascading failures. | Unlock waits for billing, analytics and email to answer. |
| Design for failure | Every call can be slow or fail. | No timeouts, no retries with backoff, no circuit breakers. |
4. How it goes wrong
5. The code
Rides, Docks and Billing as separate services, each with its own store. Unlock calls Docks synchronously; return publishes an event that Billing consumes later. Billing is down at first, then catches up, and ignores a redelivered duplicate.
from __future__ import annotations
from collections import deque
# Three services, each deployed alone and owning its own data. Here they're objects; in production, processes.
class DocksService:
def __init__(self) -> None:
self.store = {"D12": ["B7", "B9"]} # only Docks reads or writes this
def release(self, dock: str) -> str: # a synchronous API: the caller waits
return self.store[dock].pop(0)
class Queue: # a message broker between services
def __init__(self) -> None:
self.messages: deque[dict] = deque()
def publish(self, msg: dict) -> None:
self.messages.append(msg)
class BillingService:
def __init__(self) -> None:
self.store: dict[str, int] = {} # charges by event id: only Billing owns this
self.up = True
def consume(self, queue: Queue) -> None:
while queue.messages and self.up:
msg = queue.messages.popleft()
if msg["id"] in self.store: # idempotent: a redelivered event changes nothing
print(f"duplicate delivery of {msg['id']} ignored")
continue
pence = max(0, msg["minutes"] - 30) * 10 if msg["plan"] == "member" else 100 + msg["minutes"] * 10
self.store[msg["id"]] = pence
print(f"Billing charged {msg['rider']} £{pence // 100}.{pence % 100:02d} for {msg['id']}")
class RidesService:
def __init__(self, docks: DocksService, queue: Queue) -> None:
self.docks, self.queue = docks, queue
self.store: dict[str, dict] = {} # only Rides owns this
def unlock(self, rider: str, plan: str, dock: str) -> str:
bike = self.docks.release(dock) # needs an answer now: synchronous call
ride_id = f"R{len(self.store) + 1}"
self.store[ride_id] = {"rider": rider, "plan": plan, "bike": bike}
print(f"Rides -> Docks (sync): released {bike} at {dock}")
return ride_id
def give_back(self, ride_id: str, minutes: int) -> None:
ride = self.store[ride_id]
event = {"id": f"RideEnded#{ride_id}", "rider": ride["rider"], "plan": ride["plan"], "minutes": minutes}
self.queue.publish(event) # nobody needs to wait for billing: an event
print(f"Rides published {event['id']} ({ride['rider']}, {minutes} min)")
if __name__ == "__main__":
queue, docks, billing = Queue(), DocksService(), BillingService()
rides = RidesService(docks, queue)
r1 = rides.unlock("Mia", "member", "D12")
billing.up = False
rides.give_back(r1, 42)
billing.consume(queue)
print(f"Billing is down: {len(queue.messages)} event waiting in the queue")
billing.up = True
billing.consume(queue)
queue.publish({"id": "RideEnded#R1", "rider": "Mia", "plan": "member", "minutes": 42}) # the broker redelivers
billing.consume(queue)
print(f"each service owns its data: rides={len(rides.store)}, bikes at D12={len(docks.store['D12'])}, charges={len(billing.store)}")
// Three services, each deployed alone and owning its own data. Here they're objects; in production, processes.
interface Msg { id: string; rider: string; plan: string; minutes: number; }
class DocksService {
store: Record<string, string[]> = { D12: ["B7", "B9"] }; // only Docks reads or writes this
release(dock: string): string { return this.store[dock].shift() as string; } // a synchronous API: the caller waits
}
class Queue { messages: Msg[] = []; publish(m: Msg): void { this.messages.push(m); } } // a message broker between services
class BillingService {
store = new Map<string, number>(); // charges by event id: only Billing owns this
up = true;
consume(q: Queue): void {
while (q.messages.length && this.up) {
const m = q.messages.shift() as Msg;
if (this.store.has(m.id)) { console.log(`duplicate delivery of ${m.id} ignored`); continue; } // idempotent
const pence = m.plan === "member" ? Math.max(0, m.minutes - 30) * 10 : 100 + m.minutes * 10;
this.store.set(m.id, pence);
console.log(`Billing charged ${m.rider} £${Math.floor(pence / 100)}.${String(pence % 100).padStart(2, "0")} for ${m.id}`);
}
}
}
class RidesService {
store = new Map<string, { rider: string; plan: string; bike: string }>(); // only Rides owns this
constructor(private docks: DocksService, private queue: Queue) {}
unlock(rider: string, plan: string, dock: string): string {
const bike = this.docks.release(dock); // needs an answer now: synchronous call
const id = `R${this.store.size + 1}`;
this.store.set(id, { rider, plan, bike });
console.log(`Rides -> Docks (sync): released ${bike} at ${dock}`);
return id;
}
giveBack(id: string, minutes: number): void {
const ride = this.store.get(id) as { rider: string; plan: string; bike: string };
const event: Msg = { id: `RideEnded#${id}`, rider: ride.rider, plan: ride.plan, minutes };
this.queue.publish(event); // nobody needs to wait for billing: an event
console.log(`Rides published ${event.id} (${ride.rider}, ${minutes} min)`);
}
}
const queue = new Queue(), docks = new DocksService(), billing = new BillingService();
const rides = new RidesService(docks, queue);
const r1 = rides.unlock("Mia", "member", "D12");
billing.up = false;
rides.giveBack(r1, 42);
billing.consume(queue);
console.log(`Billing is down: ${queue.messages.length} event waiting in the queue`);
billing.up = true;
billing.consume(queue);
queue.publish({ id: "RideEnded#R1", rider: "Mia", plan: "member", minutes: 42 }); // the broker redelivers
billing.consume(queue);
console.log(`each service owns its data: rides=${rides.store.size}, bikes at D12=${docks.store.D12.length}, charges=${billing.store.size}`);import java.util.ArrayDeque;
import java.util.ArrayList;
import java.util.Deque;
import java.util.HashMap;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
// Three services, each deployed alone and owning its own data. Here they're objects; in production, processes.
record Msg(String id, String rider, String plan, int minutes) {}
class DocksService {
final Map<String, List<String>> store = new HashMap<>(Map.of("D12", new ArrayList<>(List.of("B7", "B9")))); // only Docks reads or writes this
String release(String dock) { return store.get(dock).remove(0); } // a synchronous API: the caller waits
}
class Queue { final Deque<Msg> messages = new ArrayDeque<>(); void publish(Msg m) { messages.add(m); } } // a message broker between services
class BillingService {
final Map<String, Integer> store = new LinkedHashMap<>(); // charges by event id: only Billing owns this
boolean up = true;
void consume(Queue q) {
while (!q.messages.isEmpty() && up) {
Msg m = q.messages.poll();
if (store.containsKey(m.id())) { System.out.println("duplicate delivery of " + m.id() + " ignored"); continue; } // idempotent
int pence = m.plan().equals("member") ? Math.max(0, m.minutes() - 30) * 10 : 100 + m.minutes() * 10;
store.put(m.id(), pence);
System.out.println("Billing charged " + m.rider() + " £" + (pence / 100) + "." + String.format("%02d", pence % 100) + " for " + m.id());
}
}
}
class RidesService {
private final DocksService docks; private final Queue queue;
final Map<String, String[]> store = new LinkedHashMap<>(); // only Rides owns this
RidesService(DocksService docks, Queue queue) { this.docks = docks; this.queue = queue; }
String unlock(String rider, String plan, String dock) {
String bike = docks.release(dock); // needs an answer now: synchronous call
String id = "R" + (store.size() + 1);
store.put(id, new String[]{rider, plan, bike});
System.out.println("Rides -> Docks (sync): released " + bike + " at " + dock);
return id;
}
void giveBack(String id, int minutes) {
String[] ride = store.get(id);
Msg event = new Msg("RideEnded#" + id, ride[0], ride[1], minutes);
queue.publish(event); // nobody needs to wait for billing: an event
System.out.println("Rides published " + event.id() + " (" + ride[0] + ", " + minutes + " min)");
}
}
public class Main {
public static void main(String[] args) {
Queue queue = new Queue(); DocksService docks = new DocksService(); BillingService billing = new BillingService();
RidesService rides = new RidesService(docks, queue);
String r1 = rides.unlock("Mia", "member", "D12");
billing.up = false;
rides.giveBack(r1, 42);
billing.consume(queue);
System.out.println("Billing is down: " + queue.messages.size() + " event waiting in the queue");
billing.up = true;
billing.consume(queue);
queue.publish(new Msg("RideEnded#R1", "Mia", "member", 42)); // the broker redelivers
billing.consume(queue);
System.out.println("each service owns its data: rides=" + rides.store.size() + ", bikes at D12=" + docks.store.get("D12").size() + ", charges=" + billing.store.size());
}
}Output (the same in all three languages)
Rides -> Docks (sync): released B7 at D12 Rides published RideEnded#R1 (Mia, 42 min) Billing is down: 1 event waiting in the queue Billing charged Mia £1.20 for RideEnded#R1 duplicate delivery of RideEnded#R1 ignored each service owns its data: rides=1, bikes at D12=1, charges=1
6. Try it
The call chain
Each service in a synchronous chain must be up for the request to succeed, and each adds latency. Set the chain and see what the rider gets.
Simplified: failures are independent, latency adds up along the chain, and asynchronous steps leave the rider's path (they still have to succeed eventually, through retries).
7. What it costs, and when to pick another style
What you get
- Independent teams: each releases on its own schedule.
- Independent scaling: only the busy service gets more copies.
- Fault isolation: a broken report service doesn't stop unlocking.
- Technology choice per service: a store or language that fits each job.
What you pay
- Distributed everything: network calls, partial failures, retries, timeouts.
- No cross-service transactions: sagas and eventual consistency instead.
- Operational load: dozens of deployables, pipelines, dashboards and on-call rotations.
- Harder debugging: a request's story is spread over many logs.
| If… | Consider instead |
|---|---|
| Few teams, or boundaries still unclear | Modular monolith, then extract |
| A handful of business areas, shared data is fine | Service-based |
| Mostly reacting to events with spiky load | Serverless functions |
| A failure must never reach all customers | Add cells around the services |
8. Signs you've outgrown it
- The number of services grows faster than the number of teams able to own them.
- Most changes need several services released together.
- On-call and platform work crowd out product work.
- Some teams quietly merge services back together; sometimes that's the right move (see Segment's case).
9. Remember it and say it
How this hook works: what “Stalls in a market” means
What the slogan means, phrase by phrase
- “Stalls”
- Services around business capabilities.
- “in a market”
- Shared rules for how they talk, never shared fridges (databases).
Why this picture fits: A food market: each stall has its own owner, kitchen and stock; they share the queue and the payment rules.
The story → the concept
| The market | Pedal |
|---|---|
| Stalls | Rides, Docks and Billing services |
| Each stall's fridge | Each service's own store |
| Shouting an order across | A synchronous call: Rides asks Docks |
| The noticeboard | Events: RideEnded |
Watch it on an example
- Unlock waits for Docks, because the rider needs a bike now.
- Return publishes an event; Billing charges when it's back up.
- A redelivered event is ignored, so nobody is charged twice.
The trap it saves you from
Splitting into services before there are teams and boundaries to justify them.
It still works when…
With cells, serverless or hexagonal insides: microservices combine with most other styles.
In an interview or design review
- Justify the split with forces: independent teams, release cadence, very different scaling, isolation, not "because it scales".
- Say sync vs async per call: "Unlock calls Docks synchronously; billing, analytics and notifications consume RideEnded."
- Cover data: database per service, an outbox for events, sagas for multi-service flows.
- Cover failure: timeouts, retries with backoff and jitter, circuit breakers, idempotent consumers, trace IDs.
10. Check yourself
Billing reads the Rides database directly to save an API call. What's the problem?
Four services in a synchronous chain are each 99.9% available. Roughly how available is the chain?
Why does Billing keep a list of event IDs it has handled?
Which is the strongest reason to adopt microservices?
11. Go deeper
| Resource | Why |
|---|---|
| Architecture 16: Microservices | How an architect weighs the style. |
| Microservices (James Lewis and Martin Fowler) | The article that named the style. |
| System design 24: Reliability patterns | Timeouts, retries, breakers and bulkheads in depth. |
| Case study: Segment back to a monolith | When a team undid its microservices. |