A match ends outside a stadium and twenty people tap "Book" at once. Three drivers are within 3 km. Three riders get a car, seventeen are told no at once, and no driver is ever sent to two pickups.
Most ride-hailing apps look the same from the outside: set a pickup and a drop, see a price, tap Book, watch a car icon move towards you. Behind that screen the interviewer wants three things: a way to find nearby drivers without scanning the whole city, a trip whose life cycle refuses nonsense like completing a cancelled ride, and the race every candidate should see coming: two riders whose searches return the same nearest driver.
This lesson builds it in one process. The fleet-scale version, with millions of location pings and geo-sharded dispatch, is a high-level design question; the compare-and-set that keeps a driver on one trip is the same at both sizes.
How the round runs#
"Design the core of a ride-hailing app. Riders request a ride from A to B; we find a nearby driver, run the trip and charge a fare. Prices go up when demand is high. Two riders must never get the same driver. Working code with concurrent requests, 90 minutes. Maps, ETAs and payments are out of scope."
- 0–10 min — clarify: search radius, how matching picks, trip states, fare formula, how surge works, cancellation fees
- 10–25 min — the classes, the trip state machine, the two strategies
- 25–40 min — the location index: a grid of cells,
nearby(), location updates - 40–60 min —
RideService: request, match with compare-and-set, start, complete, cancel - 60–75 min — fares and surge; one normal ride, then 20 threads for 3 drivers
- 75–90 min — follow-ups: driver accept/decline, ride types, pooling, scale
Requirements and clarifying questions#
| You ask | They answer | What it changes |
|---|---|---|
| How far do we look for a driver? | 3 km from the pickup. | A spatial index; a radius on the query |
| Which driver gets the ride? | The nearest for now; we'll tune it. | A MatchingStrategy |
| Does the driver accept or decline? | Auto-assign for now; offers are a follow-up. | Matching reserves the driver directly |
| How is the fare worked out? | Base + per km + per minute, with a minimum. | A FareStrategy |
| Surge? | Demand over supply nearby, capped at 2x. | A SurgePolicy and a decorator on the fare |
| When is surge fixed? | When the rider books: they agreed to that price. | The multiplier is stored on the trip |
| Can a rider cancel? | Free for 2 minutes after matching, then ₹50; not once started. | A cancellation fee and a state check |
Two riders, one nearest driver
Riya and Kabir request at the same moment from the same corner. Both calls to nearby() return Ravi as the closest available driver. What stops both trips from being assigned to Ravi, and where should that check live?
One strong answer
nearby() is a snapshot, stale the moment it returns. The assignment must be a compare-and-set on the driver: "if Ravi is still AVAILABLE, make him ON_TRIP for this trip", atomically, under Ravi's own lock. Exactly one call sees AVAILABLE; the other gets False and tries the next driver in its list. The check lives on the Driver because the search can never be up to date.
The classes#
%% title: Ride sharing: the classes
classDiagram
class Driver {
+driver_id str
+location Location
+status DriverStatus
+try_reserve(trip_id) bool
+release()
}
class Trip {
+trip_id str
+rider str
+pickup Location
+drop Location
+surge float
+status TripStatus
+fare int
+move(to)
}
class DriverIndex {
-cells dict
+update_location(driver_id, loc)
+nearby(loc, radius) list
}
class MatchingStrategy {
<<interface>>
+rank(pickup, candidates) list
}
class FareStrategy {
<<interface>>
+fare(km, minutes) int
}
class SurgePolicy {
+cap float
+multiplier(demand, supply) float
}
class RideService {
+request(rider, pickup, drop, now) Trip
+start(trip_id)
+complete(trip_id, km, minutes) Trip
+cancel(trip_id, now) int
}
RideService --> DriverIndex
RideService --> MatchingStrategy
RideService --> FareStrategy
RideService --> SurgePolicy
RideService "1" o-- "many" Trip
DriverIndex "1" o-- "many" Driver
MatchingStrategy <|.. NearestFirst
FareStrategy <|.. StandardFare
FareStrategy <|.. SurgeFare
SurgeFare --> FareStrategyThe trip's life cycle is small, and the design depends on it being strict:
%% title: Ride sharing: trip states stateDiagram-v2 [*] --> Requested Requested --> Accepted: a driver is reserved Requested --> Cancelled: no driver nearby Accepted --> Started: driver taps Start Accepted --> Cancelled: rider cancels, fee after 2 min Started --> Completed: driver taps End, fare computed Completed --> [*] Cancelled --> [*]
Each kind of state has one owner: where drivers are belongs to DriverIndex; whether a driver is free to the Driver, changed only by try_reserve() and release(); where a trip is in its life to the Trip, changed only by move(), which checks the ALLOWED table.
The interfaces#
Locations are kilometres on a flat grid to keep the arithmetic readable; a real system uses latitude, longitude and haversine. try_reserve is the method that closes the race:
# ride.py, part 1: places, drivers, and the trip's states
from __future__ import annotations
import itertools
import math
import threading
from abc import ABC, abstractmethod
from collections import defaultdict, deque
from dataclasses import dataclass, field
from enum import Enum
@dataclass(frozen=True)
class Location:
x: float # km east of a reference point; production uses lat/lng and haversine
y: float # km north
def km_to(self, other: Location) -> float:
return math.hypot(self.x - other.x, self.y - other.y)
class DriverStatus(Enum):
OFFLINE = "offline"
AVAILABLE = "available"
ON_TRIP = "on_trip"
@dataclass
class Driver:
driver_id: str
name: str
location: Location
status: DriverStatus = DriverStatus.AVAILABLE
trip_id: str | None = None
_lock: threading.Lock = field(default_factory=threading.Lock, repr=False)
def try_reserve(self, trip_id: str) -> bool:
"""Compare-and-set AVAILABLE -> ON_TRIP: of two riders, exactly one wins this driver."""
with self._lock:
if self.status is not DriverStatus.AVAILABLE:
return False
self.status, self.trip_id = DriverStatus.ON_TRIP, trip_id
return True
def release(self) -> None:
with self._lock:
self.status, self.trip_id = DriverStatus.AVAILABLE, None
class TripStatus(Enum):
REQUESTED = "requested"
ACCEPTED = "accepted"
STARTED = "started"
COMPLETED = "completed"
CANCELLED = "cancelled"
ALLOWED = {
TripStatus.REQUESTED: {TripStatus.ACCEPTED, TripStatus.CANCELLED},
TripStatus.ACCEPTED: {TripStatus.STARTED, TripStatus.CANCELLED},
TripStatus.STARTED: {TripStatus.COMPLETED},
TripStatus.COMPLETED: set(),
TripStatus.CANCELLED: set(),
}
class InvalidTransition(Exception): ...
@dataclass
class Trip:
trip_id: str
rider: str
pickup: Location
drop: Location
surge: float = 1.0 # locked in at request time, never recomputed at the drop
status: TripStatus = TripStatus.REQUESTED
driver_id: str | None = None
accepted_at: float | None = None
fare: int | None = None
_lock: threading.Lock = field(default_factory=threading.Lock, repr=False)
def move(self, to: TripStatus) -> None:
with self._lock: # the rider's Cancel and the driver's Start can't both win
if to not in ALLOWED[self.status]:
raise InvalidTransition(f"{self.trip_id}: {self.status.value} -> {to.value}")
self.status = toThe index keeps a set of driver ids per 1 km cell. A query looks only at the cells the search circle can touch, then measures the real distance:
# part 2: where the drivers are: a grid of 1 km cells
class DriverIndex:
def __init__(self, cell_km: float = 1.0) -> None:
self.cell_km = cell_km
self._cells: dict[tuple[int, int], set[str]] = defaultdict(set)
self._drivers: dict[str, Driver] = {}
self._mu = threading.Lock()
def _cell(self, loc: Location) -> tuple[int, int]:
return math.floor(loc.x / self.cell_km), math.floor(loc.y / self.cell_km)
def add(self, d: Driver) -> None:
with self._mu:
self._drivers[d.driver_id] = d
self._cells[self._cell(d.location)].add(d.driver_id)
def get(self, driver_id: str) -> Driver:
return self._drivers[driver_id]
def update_location(self, driver_id: str, loc: Location) -> None:
with self._mu: # the driver app pings every few seconds
d = self._drivers[driver_id]
self._cells[self._cell(d.location)].discard(driver_id)
d.location = loc
self._cells[self._cell(loc)].add(driver_id)
def nearby(self, loc: Location, radius_km: float) -> list[tuple[float, Driver]]:
"""Available drivers within the radius. A snapshot: try_reserve is the real check."""
r = math.ceil(radius_km / self.cell_km)
cx, cy = self._cell(loc)
found = []
with self._mu:
for dx in range(-r, r + 1):
for dy in range(-r, r + 1):
for did in self._cells.get((cx + dx, cy + dy), ()):
d = self._drivers[did]
km = d.location.km_to(loc)
if km <= radius_km and d.status is DriverStatus.AVAILABLE:
found.append((km, d))
return foundWith a 3 km radius, nearby() looks at a 7 × 7 block of cells whether the city has 50 drivers or 50,000. That is the idea behind geohashes and the hexagonal grids some ride-hailing systems use, in a version you can write in ten minutes.
Matching and pricing: two strategies#
Matching ranks candidates; it reserves nobody. Pricing turns distance and time into rupees, and surge wraps any tariff as a decorator:
# part 3: the two strategies: who gets the ride, and what it costs
class MatchingStrategy(ABC):
@abstractmethod
def rank(self, pickup: Location, candidates: list[tuple[float, Driver]]) -> list[Driver]: ...
class NearestFirst(MatchingStrategy):
def rank(self, pickup: Location, candidates: list[tuple[float, Driver]]) -> list[Driver]:
return [d for _, d in sorted(candidates, key=lambda c: (c[0], c[1].driver_id))]
class FareStrategy(ABC):
@abstractmethod
def fare(self, km: float, minutes: float) -> int: ...
@dataclass(frozen=True)
class StandardFare(FareStrategy):
base: int = 50 # rupees
per_km: int = 12
per_min: int = 2
minimum: int = 80
def fare(self, km: float, minutes: float) -> int:
return max(self.minimum, round(self.base + self.per_km * km + self.per_min * minutes))
class SurgeFare(FareStrategy):
"""Decorator: any fare strategy, times the multiplier the rider agreed to."""
def __init__(self, inner: FareStrategy, multiplier: float) -> None:
self.inner, self.multiplier = inner, multiplier
def fare(self, km: float, minutes: float) -> int:
return round(self.inner.fare(km, minutes) * self.multiplier)
@dataclass(frozen=True)
class SurgePolicy:
cap: float = 2.0
def multiplier(self, demand: int, supply: int) -> float:
return min(self.cap, max(1.0, round(demand / max(supply, 1), 1)))The surge rule is deliberately plain: requests within 3 km in the last 10 minutes, divided by free drivers within 3 km, kept between 1.0x and 2.0x. What matters more is when it applies: once, at request time, stored on the trip. Computed at drop-off, it would charge a rider who booked at 1.0x for demand that arrived while they sat in the car.
The patterns, and why#
| Pattern | Where | Why it earns its place |
|---|---|---|
| Strategy | MatchingStrategy, FareStrategy |
Matching rules (nearest, best-rated, shortest ETA) and tariffs (car, auto, bike) are what the business changes most. Each is a new class. |
| Decorator | SurgeFare(inner, multiplier) |
Surge applies to any tariff without each tariff knowing about it. |
| State (as a transition table) | TripStatus + ALLOWED |
Every operation is a transition; the table refuses the rest in one place. |
| Compare-and-set | Driver.try_reserve |
The atomic "if free, take" that makes a stale search harmless. |
| Facade | RideService |
The API layer calls four methods and never touches cells, locks or fares. |
Concurrency and edge cases#
Three shared things, three locks:
- The index lock guards the cell map, held briefly by pings and
nearby().nearby()reads driver status without the driver's lock, on purpose: the answer is only a hint. - Each driver's lock is taken only by
try_reserveandrelease, so only two requests that want the same driver ever contend. - Each trip's lock sits inside
move(). The rider's Cancel and the driver's Start can arrive in the same millisecond; one moves the trip, the other getsInvalidTransition. Without it you get a trip both started and cancelled.
The service lock guards only bookkeeping (ids, the one-open-trip-per-rider map, the demand window); no driver lock is taken inside it.
- A rider taps Book twice: the second request fails while the first trip is open; one rider, one open trip
- No driver within the radius: the trip moves to CANCELLED and the rider is told at once, not left waiting on a spinner
- The nearest driver is reserved between the search and the reservation:
try_reservefails and the loop tries the next one - Cancel after the trip started: refused by the state table; ending the ride is the driver's End button
- Surge changes during the trip: irrelevant; the multiplier was stored at request time
The code#
The service ties the index, the two strategies and the state machine together:
# part 4: the service: request, start, complete, cancel
class NoDriverNearby(Exception): ...
@dataclass
class RideService:
index: DriverIndex
matcher: MatchingStrategy
pricing: FareStrategy
surge: SurgePolicy = field(default_factory=SurgePolicy)
radius_km: float = 3.0
window_s: float = 600.0 # demand = requests nearby in the last 10 minutes
free_cancel_s: float = 120.0
cancel_fee: int = 50
trips: dict[str, Trip] = field(default_factory=dict)
_active: dict[str, str] = field(default_factory=dict) # rider -> open trip
_recent: deque = field(default_factory=deque) # (time, pickup) of recent requests
_ids: itertools.count = field(default_factory=lambda: itertools.count(1))
_mu: threading.Lock = field(default_factory=threading.Lock)
def request(self, rider: str, pickup: Location, drop: Location, now: float) -> Trip:
with self._mu:
if rider in self._active:
raise ValueError(f"{rider} already has trip {self._active[rider]}")
self._recent.append((now, pickup))
while self._recent[0][0] < now - self.window_s:
self._recent.popleft()
demand = sum(1 for _, p in self._recent if p.km_to(pickup) <= self.radius_km)
trip = Trip(f"T{next(self._ids):04d}", rider, pickup, drop)
self.trips[trip.trip_id] = trip
self._active[rider] = trip.trip_id
candidates = self.index.nearby(pickup, self.radius_km)
trip.surge = self.surge.multiplier(demand, len(candidates))
for d in self.matcher.rank(pickup, candidates):
if d.try_reserve(trip.trip_id): # lost the race for this driver? try the next one
trip.driver_id, trip.accepted_at = d.driver_id, now
trip.move(TripStatus.ACCEPTED)
return trip
trip.move(TripStatus.CANCELLED)
self._close(trip)
raise NoDriverNearby(f"no driver within {self.radius_km} km")
def start(self, trip_id: str) -> None:
self.trips[trip_id].move(TripStatus.STARTED)
def complete(self, trip_id: str, km: float, minutes: float) -> Trip:
trip = self.trips[trip_id]
trip.move(TripStatus.COMPLETED)
trip.fare = SurgeFare(self.pricing, trip.surge).fare(km, minutes)
self.index.update_location(trip.driver_id, trip.drop) # the driver is where the rider got off
self.index.get(trip.driver_id).release()
self._close(trip)
return trip
def cancel(self, trip_id: str, now: float) -> int:
trip = self.trips[trip_id]
trip.move(TripStatus.CANCELLED) # raises once the trip has started
late = trip.accepted_at is not None and now - trip.accepted_at > self.free_cancel_s
trip.fare = self.cancel_fee if late else 0
if trip.driver_id:
self.index.get(trip.driver_id).release()
self._close(trip)
return trip.fare
def _close(self, trip: Trip) -> None:
with self._mu:
self._active.pop(trip.rider, None)The search and the reservation loop run outside the service lock. complete() moves the driver to the drop point before releasing him, so his next match is measured from where he really is.
One ordinary ride, then the stadium:
# part 5: one ordinary ride, then twenty riders and three drivers outside a stadium
if __name__ == "__main__":
index = DriverIndex()
for did, name, x, y in [("D1", "Ravi", 0.5, 0.2), ("D2", "Sana", 2.5, 1.0), ("D3", "Imran", 10.2, 10.1),
("D4", "Meera", 10.0, 10.6), ("D5", "Arjun", 11.1, 9.8)]:
index.add(Driver(did, name, Location(x, y)))
svc = RideService(index, NearestFirst(), StandardFare())
t = svc.request("asha", Location(0, 0), Location(6, 8), now=0)
print(t.trip_id, t.status.value, "driver", t.driver_id, "surge", t.surge) # D1 is 0.54 km away
svc.start(t.trip_id)
svc.complete(t.trip_id, km=10, minutes=25)
print(t.trip_id, t.status.value, "fare", t.fare) # 50 + 12*10 + 2*25 = 220
try:
svc.cancel(t.trip_id, now=1600)
except InvalidTransition as e:
print("rejected:", e)
stadium, won, refused = Location(10, 10), [], []
def ride(i: int) -> None:
try:
won.append(svc.request(f"fan{i}", stadium, Location(10.5, 10.5), now=2000))
except NoDriverNearby:
refused.append(i)
threads = [threading.Thread(target=ride, args=(i,)) for i in range(20)]
for th in threads:
th.start()
for th in threads:
th.join()
drivers = sorted(tr.driver_id for tr in won)
print("matched", len(won), "| no driver", len(refused), "| drivers", drivers, "| double-booked", len(drivers) - len(set(drivers)))
by_driver = {tr.driver_id: tr for tr in won}
print("cancel after 200 s, fee", svc.cancel(by_driver["D4"].trip_id, now=2200), "| D4", index.get("D4").status.value)
svc.start(by_driver["D3"].trip_id)
svc.complete(by_driver["D3"].trip_id, km=1, minutes=4) # D3 drops its fan 0.7 km from the gate
late = svc.request("neha", stadium, Location(13, 14), now=2400) # 21 requests nearby, 2 free drivers
print(late.trip_id, "driver", late.driver_id, "surge", late.surge)
svc.start(late.trip_id)
print("fare", svc.complete(late.trip_id, km=5, minutes=15).fare) # (50 + 60 + 30) * 2.0 = 280T0001 accepted driver D1 surge 1.0
T0001 completed fare 220
rejected: T0001: completed -> cancelled
matched 3 | no driver 17 | drivers ['D3', 'D4', 'D5'] | double-booked 0
cancel after 200 s, fee 50 | D4 available
T0022 driver D4 surge 2.0
fare 280Asha's ride goes to Ravi, 0.54 km away, for ₹50 + ₹120 + ₹50 = ₹220. At the stadium, 20 threads race for three drivers: three trips, seventeen refusals, no driver twice, on every run. Meera's rider cancels after 200 seconds and pays ₹50. Neha books when 21 recent requests face 2 free drivers, so she pays the 2.0x cap: (₹50 + ₹60 + ₹30) × 2 = ₹280.
Extending it: the follow-ups#
- "Drivers should accept or decline." Reserve the driver, send an offer with a 15-second timer; on decline or timeout, release him and try the next. The trip stays REQUESTED meanwhile, and the reservation stops two offers reaching one driver.
- "Car, auto and bike." A
vehicle_typeon the driver, a filter innearby(), and oneFareStrategyper type. Surge wraps each one unchanged. - "Pool rides." A trip gets several riders and a route; matching asks "whose route absorbs this pickup with under 5 minutes of detour?" A new
MatchingStrategyand aRoute, not new trip states. - "Rank by ETA, not distance." Replace
NearestFirstwith a strategy that asks a routing service for ETAs for the top 10 by distance. Straight-line distance stays as the cheap first filter. - "A whole country of drivers." Shard the index by region (cells give you the key) and make
try_reservea conditional write on the driver's row. The compare-and-set doesn't change; only where it runs.
Common mistakes#
- Scanning every driver on every request instead of indexing by location
- Search, then assign with no atomic check on the driver, so two riders get the same car
- One global lock around the whole request, so every booking in the city waits in line
- A status string with no transition rules, so a cancelled trip can still be completed and billed
- Computing surge at drop-off, charging the rider a price they never saw
- Fare logic inside
Trip, so each new tariff is anifin the trip class
Ride sharing
Q1/3 Two riders' searches both return Ravi as the nearest available driver. What makes sure only one of them gets him?
Answer & explanation
Answer: B
A second search is just another stale snapshot. The reservation must check and change the driver's status in one atomic step; the loser moves on to the next driver.
Q2/3 Why is the surge multiplier stored on the trip when it's requested?
Answer & explanation
Answer: C
A quote is a promise. Fixing the multiplier at booking means the fare depends only on distance, time and the price the rider accepted.
Q3/3 The rider taps Cancel just as the driver taps Start. What happens in this design?
Answer & explanation
Answer: A
move()checks the allowed transitions and changes the status under one lock. Accepted → Started and Accepted → Cancelled can't both happen; whichever comes second finds the trip in a state that doesn't allow it.
New lessons by email
New lessons, side quests and case files as they ship, at most once a week. No spam, unsubscribe anytime.
Discussion · …
Public: everyone reading this lesson sees comments. Be kind and keep it on the material.