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
1 from __future__ import annotations
2 from abc import ABC, abstractmethod
3 from enum import Enum
4 from dataclasses import dataclass, field
5 from typing import Protocol
6 from threading import Lock
7 import uuid
8 import math
9
10
11 class RideStatus(Enum):
12 REQUESTED = "requested"
13 MATCHED = "matched"
14 EN_ROUTE = "en_route"
15 IN_PROGRESS = "in_progress"
16 COMPLETED = "completed"
17 CANCELLED = "cancelled"
18
19
20 VALID_TRANSITIONS: dict[RideStatus, set[RideStatus]] = {
21 RideStatus.REQUESTED: {RideStatus.MATCHED, RideStatus.CANCELLED},
22 RideStatus.MATCHED: {RideStatus.EN_ROUTE, RideStatus.CANCELLED},
23 RideStatus.EN_ROUTE: {RideStatus.IN_PROGRESS, RideStatus.CANCELLED},
24 RideStatus.IN_PROGRESS: {RideStatus.COMPLETED},
25 RideStatus.COMPLETED: set(),
26 RideStatus.CANCELLED: set(),
27 }
28
29
30 @dataclass
31 class Location:
32 lat: float
33 lng: float
34
35 def distance_to(self, other: "Location") -> float:
36 """Haversine distance in km."""
37 R = 6371
38 lat1, lat2 = math.radians(self.lat), math.radians(other.lat)
39 dlat = math.radians(other.lat - self.lat)
40 dlng = math.radians(other.lng - self.lng)
41 a = (math.sin(dlat / 2) ** 2
42 + math.cos(lat1) * math.cos(lat2) * math.sin(dlng / 2) ** 2)
43 return R * 2 * math.atan2(math.sqrt(a), math.sqrt(1 - a))
44
45
46 @dataclass
47 class Rider:
48 id: str
49 name: str
50 rating: float = 5.0
51
52
53 @dataclass
54 class Driver:
55 id: str
56 name: str
57 location: Location
58 rating: float = 4.5
59 available: bool = True
60
61 def __hash__(self):
62 return hash(self.id)
63
64
65 class RideObserver(ABC):
66 @abstractmethod
67 def on_status_change(self, ride_id: str, rider_name: str,
68 driver_name: str | None,
69 old_status: RideStatus, new_status: RideStatus) -> None: ...
70
71
72 class ConsoleNotifier(RideObserver):
73 def on_status_change(self, ride_id: str, rider_name: str,
74 driver_name: str | None,
75 old_status: RideStatus, new_status: RideStatus) -> None:
76 driver_info = f" (driver: {driver_name})" if driver_name else ""
77 print(f" [RIDE {ride_id}] {old_status.value} -> {new_status.value} | "
78 f"rider: {rider_name}{driver_info}")
79
80
81 class Ride:
82 def __init__(self, ride_id: str, rider: Rider, pickup: Location, dropoff: Location):
83 self.id = ride_id
84 self.rider = rider
85 self.driver: Driver | None = None
86 self.pickup = pickup
87 self.dropoff = dropoff
88 self._status = RideStatus.REQUESTED
89 self.fare: float = 0.0
90 self._observers: list[RideObserver] = []
91
92 @property
93 def status(self) -> RideStatus:
94 return self._status
95
96 def add_observer(self, observer: RideObserver) -> None:
97 self._observers.append(observer)
98
99 def transition_to(self, new_status: RideStatus) -> None:
100 if new_status not in VALID_TRANSITIONS[self._status]:
101 raise ValueError(
102 f"Invalid transition from {self._status.value} to {new_status.value}")
103 old = self._status
104 self._status = new_status
105 driver_name = self.driver.name if self.driver else None
106 for obs in self._observers:
107 obs.on_status_change(self.id, self.rider.name, driver_name, old, new_status)
108
109 def assign_driver(self, driver: Driver) -> None:
110 self.driver = driver
111 self.transition_to(RideStatus.MATCHED)
112
113
114 class MatchingStrategy(Protocol):
115 def match(self, pickup: Location, drivers: list[Driver]) -> Driver | None: ...
116
117
118 class NearestDriverStrategy:
119 """Pick the closest available driver to the pickup point."""
120 def match(self, pickup: Location, drivers: list[Driver]) -> Driver | None:
121 available = [d for d in drivers if d.available]
122 if not available:
123 return None
124 return min(available, key=lambda d: d.location.distance_to(pickup))
125
126
127 class RatingBasedStrategy:
128 """Pick the highest-rated available driver within a radius."""
129 def __init__(self, max_radius_km: float = 10.0):
130 self._max_radius = max_radius_km
131
132 def match(self, pickup: Location, drivers: list[Driver]) -> Driver | None:
133 nearby = [d for d in drivers
134 if d.available and d.location.distance_to(pickup) <= self._max_radius]
135 if not nearby:
136 return None
137 return max(nearby, key=lambda d: d.rating)
138
139
140 class PricingStrategy(Protocol):
141 def calculate_fare(self, pickup: Location, dropoff: Location,
142 surge: float) -> float: ...
143
144
145 class DistanceBasedPricing:
146 """Base fare plus per-km rate with surge multiplier."""
147 def __init__(self, base_fare: float = 50.0, rate_per_km: float = 12.0):
148 self._base_fare = base_fare
149 self._rate_per_km = rate_per_km
150
151 def calculate_fare(self, pickup: Location, dropoff: Location,
152 surge: float = 1.0) -> float:
153 distance = pickup.distance_to(dropoff)
154 return round((self._base_fare + self._rate_per_km * distance) * surge, 2)
155
156
157 class RideService:
158 def __init__(self, matching: MatchingStrategy | None = None,
159 pricing: PricingStrategy | None = None):
160 self._drivers: dict[str, Driver] = {}
161 self._rides: dict[str, Ride] = {}
162 self._matching = matching or NearestDriverStrategy()
163 self._pricing = pricing or DistanceBasedPricing()
164 self._notifier = ConsoleNotifier()
165 self._lock = Lock()
166 self._surge_multiplier = 1.0
167
168 def register_driver(self, driver: Driver) -> None:
169 self._drivers[driver.id] = driver
170
171 def set_surge(self, multiplier: float) -> None:
172 self._surge_multiplier = multiplier
173
174 def request_ride(self, rider: Rider, pickup: Location, dropoff: Location) -> Ride:
175 with self._lock:
176 ride_id = str(uuid.uuid4())[:8]
177 ride = Ride(ride_id, rider, pickup, dropoff)
178 ride.add_observer(self._notifier)
179 self._rides[ride_id] = ride
180
181 driver = self._matching.match(pickup, list(self._drivers.values()))
182 if driver is None:
183 raise RuntimeError("No drivers available")
184 driver.available = False
185 ride.assign_driver(driver)
186 return ride
187
188 def start_pickup(self, ride_id: str) -> None:
189 self._get_ride(ride_id).transition_to(RideStatus.EN_ROUTE)
190
191 def start_trip(self, ride_id: str) -> None:
192 self._get_ride(ride_id).transition_to(RideStatus.IN_PROGRESS)
193
194 def complete_ride(self, ride_id: str) -> Ride:
195 with self._lock:
196 ride = self._get_ride(ride_id)
197 ride.fare = self._pricing.calculate_fare(
198 ride.pickup, ride.dropoff, self._surge_multiplier)
199 ride.transition_to(RideStatus.COMPLETED)
200 if ride.driver:
201 ride.driver.available = True
202 return ride
203
204 def cancel_ride(self, ride_id: str) -> None:
205 with self._lock:
206 ride = self._get_ride(ride_id)
207 ride.transition_to(RideStatus.CANCELLED)
208 if ride.driver:
209 ride.driver.available = True
210
211 def _get_ride(self, ride_id: str) -> Ride:
212 ride = self._rides.get(ride_id)
213 if not ride:
214 raise ValueError(f"Ride {ride_id} not found")
215 return ride
216
217
218 if __name__ == "__main__":
219 service = RideService(
220 matching=NearestDriverStrategy(),
221 pricing=DistanceBasedPricing(base_fare=50.0, rate_per_km=12.0),
222 )
223
224 service.register_driver(Driver("d1", "Ravi", Location(12.9716, 77.5946), rating=4.8))
225 service.register_driver(Driver("d2", "Suresh", Location(12.9352, 77.6245), rating=4.9))
226 service.register_driver(Driver("d3", "Priya", Location(12.9611, 77.6387), rating=4.6))
227
228 rider = Rider("r1", "Amit")
229 pickup = Location(12.9700, 77.6000)
230 dropoff = Location(12.9250, 77.5600)
231
232 print("=== Requesting a ride ===")
233 ride = service.request_ride(rider, pickup, dropoff)
234 print(f" Matched with: {ride.driver.name}")
235
236 print("\n=== Driver heading to pickup ===")
237 service.start_pickup(ride.id)
238
239 print("\n=== Trip started ===")
240 service.start_trip(ride.id)
241
242 print("\n=== Trip completed ===")
243 completed = service.complete_ride(ride.id)
244 print(f" Fare: Rs {completed.fare}")
245
246 print("\n=== Surge pricing ride (1.8x) ===")
247 service.set_surge(1.8)
248 rider2 = Rider("r2", "Neha")
249 ride2 = service.request_ride(rider2, Location(12.9600, 77.5800), Location(12.9100, 77.5500))
250 print(f" Matched with: {ride2.driver.name}")
251 service.start_pickup(ride2.id)
252 service.start_trip(ride2.id)
253 completed2 = service.complete_ride(ride2.id)
254 print(f" Surge fare: Rs {completed2.fare}")
255
256 print("\n=== Invalid transition test ===")
257 try:
258 service.start_trip(ride.id)
259 except ValueError as e:
260 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.