Ride Sharing (Uber)
Driver matching via strategy pattern, ride lifecycle via state machine, and surge pricing that adapts to demand. The core of any ride-hailing platform in clean OOP.
Key Abstractions
Orchestrator that coordinates rider requests, driver matching, and ride lifecycle
Requester who initiates a ride with pickup and dropoff locations
Provider with real-time location, rating, and availability status
Core entity with state machine: REQUESTED -> MATCHED -> EN_ROUTE -> IN_PROGRESS -> COMPLETED
Algorithm for selecting a driver: nearest-first, rating-based, or hybrid
Fare calculation: distance-based flat rate or surge pricing during peak demand
Lat/lng coordinate pair with Haversine distance calculation
Class Diagram
The Key Insight
The interesting part of ride-sharing is not connecting a rider to a driver. That is just a lookup. The real design challenge is that every dimension of the problem needs a different algorithm, and those algorithms change constantly. How you pick a driver today (nearest) is not how you will pick one next quarter (rating-weighted with load balancing). How you price a ride at 2 PM (flat rate) differs from 6 PM (surge). If any of these decisions are hardcoded, you are rewriting core logic every sprint.
The system splits into three independent axes: matching, pricing, and ride lifecycle. Each axis is a strategy or state machine that evolves on its own schedule. RideService just wires them together. That separation is what makes the design hold up as business requirements change weekly.
Requirements
Functional
- Rider requests a ride with pickup and dropoff locations
- System matches the rider to an available driver using a configurable algorithm
- Ride progresses through defined states: requested, matched, en route, in progress, completed
- Fare calculated based on distance with support for surge pricing
- Rider can cancel a ride before it completes
- Driver becomes unavailable during a ride and available again after completion
- Status changes notify observers for real-time tracking
Non-Functional
- Thread-safe matching to prevent double-assigning a driver
- Matching and pricing strategies swappable at runtime without code changes
- State machine enforces valid transitions only
- Location distance uses Haversine formula for geographic accuracy
Design Decisions
Why Strategy for both matching and pricing?
These are independent business decisions that change at different speeds. Product might A/B test nearest-driver vs rating-weighted matching. Finance might toggle surge multipliers based on time of day. Coupling them means a pricing tweak requires retesting matching logic. Separate strategies keep these concerns isolated. Swapping either one is a constructor argument change, not a rewrite.
Why a state machine with explicit valid transitions?
Without a transition map, nothing prevents a ride from going directly from REQUESTED to COMPLETED. That means a rider gets charged for a trip no driver ever accepted. The transition map makes this physically impossible at the code level. It also serves as documentation. New engineers read the map and immediately understand every possible path a ride can take.
Why does the service manage driver availability, not the driver itself?
Drivers do not decide their own availability in the context of matching. If the driver object toggles its own available flag, you have a race condition: two concurrent requests both read available=true before either writes false. The service holds the lock and updates availability atomically as part of the matching transaction.
Why Haversine instead of Euclidean distance?
Lat/lng coordinates are not on a flat plane. Two points that are 1 degree apart in longitude at the equator and at 60N latitude represent very different real-world distances. Euclidean math on coordinates gives wrong answers. Haversine accounts for Earth's curvature and gives you actual km between two geographic points.
Interview Follow-ups
- "How would you handle ride pooling?" Add a
PoolRidethat holds multiple riders. The matching strategy groups riders by route overlap. Each rider sees their own fare based on their segment distance. - "How would you add driver ratings?" After completion, prompt the rider to rate. Store ratings on the Driver object. The
RatingBasedStrategyalready uses this field for matching decisions. - "How would you estimate arrival time?" Add an
ETACalculatorthat takes driver location, traffic data, and destination. Push ETA updates via the observer at each state transition. - "How would you handle payment failures?" Introduce an async
PaymentServicecalled during completion. If payment fails, track it separately with its own state machine (PENDING, CHARGED, FAILED, RETRYING). The ride itself stays COMPLETED.
Code Implementation
from __future__ import annotations
from abc import ABC, abstractmethod
from enum import Enum
from dataclasses import dataclass, field
from typing import Protocol
from threading import Lock
import uuid
import math
class RideStatus(Enum):
REQUESTED = "requested"
MATCHED = "matched"
EN_ROUTE = "en_route"
IN_PROGRESS = "in_progress"
COMPLETED = "completed"
CANCELLED = "cancelled"
VALID_TRANSITIONS: dict[RideStatus, set[RideStatus]] = {
RideStatus.REQUESTED: {RideStatus.MATCHED, RideStatus.CANCELLED},
RideStatus.MATCHED: {RideStatus.EN_ROUTE, RideStatus.CANCELLED},
RideStatus.EN_ROUTE: {RideStatus.IN_PROGRESS, RideStatus.CANCELLED},
RideStatus.IN_PROGRESS: {RideStatus.COMPLETED},
RideStatus.COMPLETED: set(),
RideStatus.CANCELLED: set(),
}
@dataclass
class Location:
lat: float
lng: float
def distance_to(self, other: "Location") -> float:
"""Haversine distance in km."""
R = 6371
lat1, lat2 = math.radians(self.lat), math.radians(other.lat)
dlat = math.radians(other.lat - self.lat)
dlng = math.radians(other.lng - self.lng)
a = (math.sin(dlat / 2) ** 2
+ math.cos(lat1) * math.cos(lat2) * math.sin(dlng / 2) ** 2)
return R * 2 * math.atan2(math.sqrt(a), math.sqrt(1 - a))
@dataclass
class Rider:
id: str
name: str
rating: float = 5.0
@dataclass
class Driver:
id: str
name: str
location: Location
rating: float = 4.5
available: bool = True
def __hash__(self):
return hash(self.id)
class RideObserver(ABC):
@abstractmethod
def on_status_change(self, ride_id: str, rider_name: str,
driver_name: str | None,
old_status: RideStatus, new_status: RideStatus) -> None: ...
class ConsoleNotifier(RideObserver):
def on_status_change(self, ride_id: str, rider_name: str,
driver_name: str | None,
old_status: RideStatus, new_status: RideStatus) -> None:
driver_info = f" (driver: {driver_name})" if driver_name else ""
print(f" [RIDE {ride_id}] {old_status.value} -> {new_status.value} | "
f"rider: {rider_name}{driver_info}")
class Ride:
def __init__(self, ride_id: str, rider: Rider, pickup: Location, dropoff: Location):
self.id = ride_id
self.rider = rider
self.driver: Driver | None = None
self.pickup = pickup
self.dropoff = dropoff
self._status = RideStatus.REQUESTED
self.fare: float = 0.0
self._observers: list[RideObserver] = []
@property
def status(self) -> RideStatus:
return self._status
def add_observer(self, observer: RideObserver) -> None:
self._observers.append(observer)
def transition_to(self, new_status: RideStatus) -> None:
if new_status not in VALID_TRANSITIONS[self._status]:
raise ValueError(
f"Invalid transition from {self._status.value} to {new_status.value}")
old = self._status
self._status = new_status
driver_name = self.driver.name if self.driver else None
for obs in self._observers:
obs.on_status_change(self.id, self.rider.name, driver_name, old, new_status)
def assign_driver(self, driver: Driver) -> None:
self.driver = driver
self.transition_to(RideStatus.MATCHED)
class MatchingStrategy(Protocol):
def match(self, pickup: Location, drivers: list[Driver]) -> Driver | None: ...
class NearestDriverStrategy:
"""Pick the closest available driver to the pickup point."""
def match(self, pickup: Location, drivers: list[Driver]) -> Driver | None:
available = [d for d in drivers if d.available]
if not available:
return None
return min(available, key=lambda d: d.location.distance_to(pickup))
class RatingBasedStrategy:
"""Pick the highest-rated available driver within a radius."""
def __init__(self, max_radius_km: float = 10.0):
self._max_radius = max_radius_km
def match(self, pickup: Location, drivers: list[Driver]) -> Driver | None:
nearby = [d for d in drivers
if d.available and d.location.distance_to(pickup) <= self._max_radius]
if not nearby:
return None
return max(nearby, key=lambda d: d.rating)
class PricingStrategy(Protocol):
def calculate_fare(self, pickup: Location, dropoff: Location,
surge: float) -> float: ...
class DistanceBasedPricing:
"""Base fare plus per-km rate with surge multiplier."""
def __init__(self, base_fare: float = 50.0, rate_per_km: float = 12.0):
self._base_fare = base_fare
self._rate_per_km = rate_per_km
def calculate_fare(self, pickup: Location, dropoff: Location,
surge: float = 1.0) -> float:
distance = pickup.distance_to(dropoff)
return round((self._base_fare + self._rate_per_km * distance) * surge, 2)
class RideService:
def __init__(self, matching: MatchingStrategy | None = None,
pricing: PricingStrategy | None = None):
self._drivers: dict[str, Driver] = {}
self._rides: dict[str, Ride] = {}
self._matching = matching or NearestDriverStrategy()
self._pricing = pricing or DistanceBasedPricing()
self._notifier = ConsoleNotifier()
self._lock = Lock()
self._surge_multiplier = 1.0
def register_driver(self, driver: Driver) -> None:
self._drivers[driver.id] = driver
def set_surge(self, multiplier: float) -> None:
self._surge_multiplier = multiplier
def request_ride(self, rider: Rider, pickup: Location, dropoff: Location) -> Ride:
with self._lock:
ride_id = str(uuid.uuid4())[:8]
ride = Ride(ride_id, rider, pickup, dropoff)
ride.add_observer(self._notifier)
self._rides[ride_id] = ride
driver = self._matching.match(pickup, list(self._drivers.values()))
if driver is None:
raise RuntimeError("No drivers available")
driver.available = False
ride.assign_driver(driver)
return ride
def start_pickup(self, ride_id: str) -> None:
self._get_ride(ride_id).transition_to(RideStatus.EN_ROUTE)
def start_trip(self, ride_id: str) -> None:
self._get_ride(ride_id).transition_to(RideStatus.IN_PROGRESS)
def complete_ride(self, ride_id: str) -> Ride:
with self._lock:
ride = self._get_ride(ride_id)
ride.fare = self._pricing.calculate_fare(
ride.pickup, ride.dropoff, self._surge_multiplier)
ride.transition_to(RideStatus.COMPLETED)
if ride.driver:
ride.driver.available = True
return ride
def cancel_ride(self, ride_id: str) -> None:
with self._lock:
ride = self._get_ride(ride_id)
ride.transition_to(RideStatus.CANCELLED)
if ride.driver:
ride.driver.available = True
def _get_ride(self, ride_id: str) -> Ride:
ride = self._rides.get(ride_id)
if not ride:
raise ValueError(f"Ride {ride_id} not found")
return ride
if __name__ == "__main__":
service = RideService(
matching=NearestDriverStrategy(),
pricing=DistanceBasedPricing(base_fare=50.0, rate_per_km=12.0),
)
service.register_driver(Driver("d1", "Ravi", Location(12.9716, 77.5946), rating=4.8))
service.register_driver(Driver("d2", "Suresh", Location(12.9352, 77.6245), rating=4.9))
service.register_driver(Driver("d3", "Priya", Location(12.9611, 77.6387), rating=4.6))
rider = Rider("r1", "Amit")
pickup = Location(12.9700, 77.6000)
dropoff = Location(12.9250, 77.5600)
print("=== Requesting a ride ===")
ride = service.request_ride(rider, pickup, dropoff)
print(f" Matched with: {ride.driver.name}")
print("\n=== Driver heading to pickup ===")
service.start_pickup(ride.id)
print("\n=== Trip started ===")
service.start_trip(ride.id)
print("\n=== Trip completed ===")
completed = service.complete_ride(ride.id)
print(f" Fare: Rs {completed.fare}")
print("\n=== Surge pricing ride (1.8x) ===")
service.set_surge(1.8)
rider2 = Rider("r2", "Neha")
ride2 = service.request_ride(rider2, Location(12.9600, 77.5800), Location(12.9100, 77.5500))
print(f" Matched with: {ride2.driver.name}")
service.start_pickup(ride2.id)
service.start_trip(ride2.id)
completed2 = service.complete_ride(ride2.id)
print(f" Surge fare: Rs {completed2.fare}")
print("\n=== Invalid transition test ===")
try:
service.start_trip(ride.id)
except ValueError as e:
print(f" Correctly rejected: {e}")Common Mistakes
- ✗Putting matching logic inside RideService directly. That becomes unmaintainable when you need A/B testing between matching algorithms.
- ✗Using string-based ride status instead of an enum with enforced transitions. You will end up with rides in impossible states.
- ✗Calculating distance with Euclidean formula instead of Haversine. Lat/lng coordinates are not Cartesian.
- ✗Forgetting to mark a driver as unavailable after matching. Two riders can get matched to the same driver.
Key Points
- ✓Strategy pattern for driver matching: swap between nearest-driver, highest-rated, or load-balanced without touching core logic
- ✓State machine on Ride enforces valid transitions. You cannot go from REQUESTED to IN_PROGRESS directly.
- ✓Observer pattern notifies riders of driver location updates and ride status changes
- ✓Pricing strategy is decoupled from ride logic. Surge pricing multiplier is a strategy swap, not an if-else chain.