The real-world analogy: taxi dispatchers and pricing stamps
Imagine landing at a busy airport terminal:
- 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.
- 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.
- 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:
Tripis 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.statusis 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) andFarePolicy(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.
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.
- 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 aCancellationPolicyconsulted 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 (Observer → event-driven seams);COMPLETEDis 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→ASSIGNEDwithOFFERED→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
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
Pick the aggregate root
“Trips, riders, drivers, fares, ratings — which object is the center of gravity?”
- 2
Draw the state machine with actors
“REQUESTED → … → COMPLETED. But who is allowed to trigger each transition?”
unlocks after the stage above - 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
Fares = ordered pipeline of policies
“Base + distance + time, ×surge, −promo, floor at minimum. What structure is that?”
unlocks after the stage above - 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
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
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++
#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
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.
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
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.- Campus BikesMedium
Assign each worker the closest free bike — nearest-driver matching with no double-booking.
Start → end a trip and compute the fare — the ride's check-in/check-out lifecycle.
Stretch — dispatch as drivers free up
Hand the next request to a driver the moment one frees — surge dispatch over time.