Design Uber (LLD)

The trip as a state machine, fare calculation as composable strategies, matching behind an interface — the class design beneath the geospatial giant.

LLDOODstate-machinestrategy

The real-world analogy: taxi dispatchers and pricing stamps

Imagine landing at a busy airport terminal:

  1. The Dispatcher Desk (Trip Aggregate Root): You walk up to the taxi coordinator desk. The coordinator doesn't just yell for drivers. They write your destination, rider name, and quote in a physical logbook sheet (Trip Record). This logbook is the coordinator's primary ledger—riders can't claim drivers directly; everything must pass through and mutate the trip record.
  2. The Quote Stamp (Request-time Surge Capture): Before you step outside, the coordinator looks at the queue length, stamps a price voucher showing a 1.5× surge rate, and hands it to you. That stamp is a guarantee. If your ride gets stuck in traffic for two hours, or if the airport surge drops to 1.0× while you're driving, the cashier at the exit gate charges you exactly what's printed on the voucher stamp.
  3. Pluggable Pricing Rules (Strategy Pipeline): The cashier calculates the final cost using a binder of pluggable rate sheets: Base rate page + Distance page + Surcharge multiplier page. Changing page ordering changes the result, showing they are composable Strategy layers.

Scope it first

"The object design of ride-hailing: trips and their lifecycle, riders/drivers, matching policy, fare calculation with surge, ratings. The geo-index and scale story is the HLD doc — here we design the domain model a single region's service runs. OK?"

The grading centers: a rich state machine (the trip has more states, actors and illegal transitions than any other classic), and fare calculation — the cleanest real-world showcase of Strategy + Decorator composition in the catalog.


UML Class Diagram

Modeling calls to narrate:

  • Trip is the aggregate root — the entity every other object hangs off, the unit of consistency (the precious data), and the only place state transitions happen. Riders don't set drivers; trips assign drivers.
  • Driver.status is a second, smaller state machine (AVAILABLE → OFFERED → ON_TRIP) that must stay consistent with trips — the OFFERED reservation is the double-booking guard.
  • Two strategies, two interfaces: MatchingStrategy (nearest / highest-rated / batched assignment) and FarePolicy (below) vary independently — don't fuse them into one "ConfigService."

The trip state machine (the rich one)

Step through a trip lifecycle: request → match → pickup → dropoff.

Uber trip lifecycletime O(1) per eventspace O(states)
matchpickupdropoffcancelcancelReqMatchedOnTripEnded
events:matchpickupdropoff

1/4Start in Req. Each event is handled by the current state — the State pattern moves this branching out of one giant switch and into the state objects themselves.

state = Req
  • Transitions have actors and guards: start() is driver-only and legal only from ARRIVING; cancel() is legal from many states but means something different in each — that's not one method, it's a CancellationPolicy consulted per state (free at REQUESTED, fee after the driver's driven 5 minutes toward you). Encoding "cancel" as state-dependent policy rather than an if-ladder is the design move graders wait for.
  • Every transition emits an event (TripAssigned, TripCompleted) — notifications, receipts, analytics and driver payouts subscribe (Observerevent-driven seams); COMPLETED is what triggers payment, and a payment failure does not un-complete the trip — money flows are compensated, never rewound (the saga stance).
  • Trip + driver transitions must be atomic at assignment (MATCHING→ASSIGNED with OFFERED→ON_TRIP) — same transaction or conditional update; this is where the HLD's matching race touches down in the code.

Fare calculation: strategies that compose

A fare isn't one formula — it's an ordered pipeline of policies, each transforming a running total:

Two sentences make this section senior-grade: order is semantics (surge-then-promo vs promo-then-surge are different prices — the list order is a business rule under test), and the surge multiplier is captured at request time onto the trip — the rider pays the price they were quoted, not the price at completion (the quoted-price-is-a-promise rule; money facts freeze when shown). All arithmetic in integer paise with explicit rounding policy — the Splitwise laws apply unchanged.


Think it through like the interview

Think it through: Design Uber (LLD)LLD Classic — rich state machine0/5 stages

PROBLEMDesign the domain model for ride-hailing: trips and their lifecycle, riders and drivers, matching policy, fares with surge, ratings. Geo-indexing at scale is out of scope.

  1. 1

    Pick the aggregate root

    Trips, riders, drivers, fares, ratings — which object is the center of gravity?

  2. 2

    Draw the state machine with actors

    REQUESTED → … → COMPLETED. But who is allowed to trigger each transition?

    unlocks after the stage above
  3. 3

    One verb, many meanings → policy object

    cancel() is legal from four states and means something different in each. Method or something more?

    unlocks after the stage above
  4. 4

    Fares = ordered pipeline of policies

    Base + distance + time, ×surge, −promo, floor at minimum. What structure is that?

    unlocks after the stage above
  5. 5

    The atomicity follow-up

    Assignment flips Trip(MATCHING→ASSIGNED) and Driver(OFFERED→ON_TRIP). What if those are two writes?

    unlocks after the stage above

Implementation

Below are complete implementations with composable fare calculators, aggregate trip entities, and thread-safe driver locking.

Python

Python
from abc import ABC, abstractmethod

class FarePolicy(ABC):
    @abstractmethod
    def apply(self, trip, fare: float) -> float: 
        pass

class BaseFare(FarePolicy):           # flat amount by city/vehicle class
    def __init__(self, base_rates: dict):
        self.base_rates = base_rates
        
    def apply(self, trip, fare): 
        return fare + self.base_rates.get(trip.vehicle_class, 5.0)

class DistanceTime(FarePolicy):       # per-km + per-minute
    def __init__(self, per_km: float, per_min: float):
        self.per_km = per_km
        self.per_min = per_min
        
    def apply(self, trip, fare):
        return fare + trip.km * self.per_km + trip.minutes * self.per_min

class SurgeMultiplier(FarePolicy):    # multiplies everything BEFORE it
    def apply(self, trip, fare): 
        return fare * trip.surge_at_request

class PromoDiscount(FarePolicy):      # subtracts AFTER surge, floor at minimum
    def __init__(self, min_fare: float):
        self.minimum_fare = min_fare
        
    def apply(self, trip, fare):
        discount = 2.0  # mock promo
        return max(fare - discount, self.minimum_fare)

Java

Java
import java.util.*;
import java.util.concurrent.locks.ReentrantLock;

enum TripState { REQUESTED, MATCHING, ASSIGNED, ARRIVING, IN_PROGRESS, COMPLETED, CANCELLED }
enum DriverStatus { OFFLINE, AVAILABLE, OFFERED, ON_TRIP }

class User {
    String id;
    String name;
    User(String id, String name) { this.id = id; this.name = name; }
}

class Driver {
    String id;
    DriverStatus status = DriverStatus.AVAILABLE;
    final ReentrantLock lock = new ReentrantLock();
    Driver(String id) { this.id = id; }
}

interface FarePolicy {
    long apply(double km, int minutes, double surge, long runningFare);
}

class BaseFarePolicy implements FarePolicy {
    public long apply(double km, int minutes, double surge, long runningFare) {
        return runningFare + 5000; // Flat 50.00 base fare in cents
    }
}

class DistanceTimePolicy implements FarePolicy {
    public long apply(double km, int minutes, double surge, long runningFare) {
        return runningFare + (long)(km * 150) + (long)(minutes * 50); // 1.50/km, 0.50/min
    }
}

class SurgePolicy implements FarePolicy {
    public long apply(double km, int minutes, double surge, long runningFare) {
        return (long)(runningFare * surge);
    }
}

class FareCalculator {
    private final List<FarePolicy> policies;
    FareCalculator(List<FarePolicy> policies) { this.policies = policies; }
    
    public long calculateFare(double km, int minutes, double surge) {
        long fare = 0;
        for (FarePolicy policy : policies) {
            fare = policy.apply(km, minutes, surge, fare);
        }
        return fare;
    }
}

class Trip {
    String tripId;
    User rider;
    Driver driver;
    TripState state = TripState.REQUESTED;
    double km;
    int minutes;
    double surgeAtRequest;
    long finalFareCents;
    final ReentrantLock lock = new ReentrantLock();

    Trip(String id, User rider, double surge) {
        this.tripId = id;
        this.rider = rider;
        this.surgeAtRequest = surge;
    }

    public void assignDriver(Driver d) {
        lock.lock();
        try {
            d.lock.lock();
            try {
                if (d.status != DriverStatus.AVAILABLE) {
                    throw new IllegalStateException("Driver is busy");
                }
                this.driver = d;
                this.state = TripState.ASSIGNED;
                d.status = DriverStatus.ON_TRIP;
            } finally {
                d.lock.unlock();
            }
        } finally {
            lock.unlock();
        }
    }
}

C++

C++
#include <string>
#include <vector>
#include <unordered_map>
#include <mutex>
#include <memory>
#include <algorithm>
#include <stdexcept>

enum class TripState { REQUESTED, MATCHING, ASSIGNED, ARRIVING, IN_PROGRESS, COMPLETED, CANCELLED };
enum class DriverStatus { OFFLINE, AVAILABLE, OFFERED, ON_TRIP };

class User {
public:
    std::string id;
    std::string name;
    User(std::string i, std::string n) : id(i), name(n) {}
};

class Driver {
public:
    std::string id;
    DriverStatus status = DriverStatus::AVAILABLE;
    std::mutex mtx;
    Driver(std::string i) : id(i) {}
};

class FarePolicy {
public:
    virtual ~FarePolicy() = default;
    virtual long long apply(double km, int minutes, double surge, long long runningFare) = 0;
};

class BaseFarePolicy : public FarePolicy {
public:
    long long apply(double km, int minutes, double surge, long long runningFare) override {
        return runningFare + 5000; // Base flat 50.00
    }
};

class DistanceTimePolicy : public FarePolicy {
public:
    long long apply(double km, int minutes, double surge, long long runningFare) override {
        return runningFare + static_cast<long long>(km * 150) + static_cast<long long>(minutes * 50);
    }
};

class SurgePolicy : public FarePolicy {
public:
    long long apply(double km, int minutes, double surge, long long runningFare) override {
        return static_cast<long long>(runningFare * surge);
    }
};

class FareCalculator {
private:
    std::vector<std::shared_ptr<FarePolicy>> policies;
public:
    FareCalculator(const std::vector<std::shared_ptr<FarePolicy>>& p) : policies(p) {}
    
    long long calculateFare(double km, int minutes, double surge) {
        long long fare = 0;
        for (const auto& policy : policies) {
            fare = policy->apply(km, minutes, surge, fare);
        }
        return fare;
    }
};

class Trip {
public:
    std::string tripId;
    std::shared_ptr<User> rider;
    std::shared_ptr<Driver> driver;
    TripState state = TripState::REQUESTED;
    double km = 0.0;
    int minutes = 0;
    double surgeAtRequest = 1.0;
    long long finalFareCents = 0;
    std::mutex mtx;

    Trip(std::string id, std::shared_ptr<User> r, double surge) 
        : tripId(id), rider(r), surgeAtRequest(surge) {}

    void assignDriver(std::shared_ptr<Driver> d) {
        std::lock_guard<std::mutex> lockTrip(mtx);
        std::lock_guard<std::mutex> lockDriver(d->mtx);

        if (d->status != DriverStatus::AVAILABLE) {
            throw std::runtime_error("Driver is busy");
        }
        driver = d;
        state = TripState::ASSIGNED;
        d->status = DriverStatus::ON_TRIP;
    }
};

Interactive Quiz

Check yourself0/3 answered

1.

2.

3.


Walk a scenario

Rider requests: Trip(REQUESTED), surge 1.4× stamped on it → MATCHING; NearestDriver strategy picks from the geo-index's candidates; offer → accept → atomic ASSIGNED + driver ON_TRIP; TripAssigned event → rider's app shows the car (live tracking is HLD). Driver arrives, taps start (guard: state == ARRIVING ✓) → IN_PROGRESS; arrival → complete() → fare pipeline runs: base 50 + (12 km, 31 min → 230) = 280, ×1.4 surge = 392, promo −50 = ₹342 → TripCompleted → payment service charges (idempotency key = trip id), receipt notification, both parties prompted to rate (RatingService accepts only for COMPLETED trips, once per side — two more guards). A cancellation at ARRIVING instead would have consulted CancellationPolicy(ARRIVING) → ₹40 fee, driver released to AVAILABLE, that policy decision logged onto the trip for the inevitable support ticket.


Q&A


Practice — level up

Ride-hailing is nearest-match plus a trip lifecycle: find the closest free driver, assign exactly one, then run the trip from request to fare. These drills rehearse the matching and the hand-off.

Practice ladder: Matching & trip lifecycle0/4 solved

Climb in order — every rung assumes the one above it. Solve on LeetCode, then tick it here; progress is saved on this device.

Warm-up — who's nearest

  1. Rank candidates by distance — the nearest free drivers to a rider.

Core — assign one, track the trip

Match a rider to a driver; meter the ride.
  1. Assign each worker the closest free bike — nearest-driver matching with no double-booking.

  2. Start → end a trip and compute the fare — the ride's check-in/check-out lifecycle.

Stretch — dispatch as drivers free up

  1. Hand the next request to a driver the moment one frees — surge dispatch over time.