~learn.

LLD 1413 min+60 XP

Design a Ride-Sharing Service.

Design riders, drivers, matching and fares, then make them race

6 code

Lesson · 13 min

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#

Interviewer

"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."

A good 90 minutes
  • 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
Your turn · checkpoint 1not answered

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?

Think it through, or jot it down. Then compare.

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#

DiagramRide sharing: 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 --> FareStrategy

The trip's life cycle is small, and the design depends on it being strict:

DiagramRide sharing: trip states
%% 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:

pythonLocation · 87 lines
# 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 = to

The 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:

pythonDriverIndex · 40 lines
# 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 found

With 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:

pythonMatchingStrategy · 43 lines
# 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_reserve and release, 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 gets InvalidTransition. 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.

Edge cases
  • 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_reserve fails 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:

pythonNoDriverNearby · 67 lines
# 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:

pythonride · 43 lines
# 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 = 280
text7 lines
T0001 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 280

Asha'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_type on the driver, a filter in nearby(), and one FareStrategy per 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 MatchingStrategy and a Route, not new trip states.
  • "Rank by ETA, not distance." Replace NearestFirst with 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_reserve a conditional write on the driver's row. The compare-and-set doesn't change; only where it runs.
✗ Pitfall

Common mistakes#

What costs candidates the round
  • 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 an if in the trip class
Quick check3 questions · +5 XP each

Ride sharing

  1. 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.

  2. 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.

  3. 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.

End of LLD 14 · Design a Ride-Sharing Service
Finish LLD 14Stages clear as you read them (0/1 so far). Skipping ahead doesn't count: marking complete unlocks after ~3 more min of reading.

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.

Sign in to ask a question or answer one. Reading stays open to everyone.
loading the discussion…