LLD for Ride Booking App: Designing Uber/Lyft
How do you match a rider with a driver in real-time? We break down the Low-Level Design of a ride-sharing app using the Observer and Strategy patterns
Abstract Algorithms
TLDR: A ride-booking system (Uber/Lyft-style) needs three interleaved sub-systems: real-time driver location tracking (Observer Pattern), nearest-driver matching (geospatial query), and dynamic pricing (Strategy Pattern). Getting state transitions right โ Requested โ Accepted โ InProgress โ Completed โ is the core LLD challenge.
๐ The Matchmaking Problem Behind Every Ride Request
When a rider taps "Book Ride," a deceptively complex chain of decisions fires within seconds:
- Which drivers are available near the pickup point?
- Which one is closest?
- What price should be quoted?
- How does the trip state transition as the ride progresses?
Low-Level Design asks: what are the classes, interfaces, enums, and relationships that implement this correctly?
This post focuses on three core design decisions and the patterns that make them extensible.
๐ข Core Entities and Their Relationships
Rider โ requests a trip, has a current location.
Driver โ has availability status, current location, vehicle info.
Trip โ ties a rider to a driver, from pickup to drop-off.
classDiagram
class Rider {
+String id
+String name
+Location location
+requestRide(Location destination)
}
class Driver {
+String id
+String name
+DriverStatus status
+Location location
+updateLocation(Location loc)
}
class Trip {
+String tripId
+Rider rider
+Driver driver
+TripStatus status
+Location pickup
+Location destination
+transition(TripEvent event)
}
Rider --> Trip : initiates
Driver --> Trip : fulfills
TripStatus enum:
enum TripStatus {
REQUESTED, ACCEPTED, IN_PROGRESS, COMPLETED, CANCELLED
}
DriverStatus enum:
enum DriverStatus {
AVAILABLE, BUSY, OFFLINE
}
๐ Driver Availability State Machine
stateDiagram-v2
[*] --> AVAILABLE : driver goes online
AVAILABLE --> BUSY : acceptRide() called
BUSY --> AVAILABLE : completeRide() called
BUSY --> AVAILABLE : trip cancelled
AVAILABLE --> OFFLINE : driver logs out
BUSY --> OFFLINE : emergency force-quit
OFFLINE --> AVAILABLE : driver comes back online
โ๏ธ State Transitions: The Trip Lifecycle
Every transition is triggered by an explicit event. Wrong transitions must be rejected.
stateDiagram-v2
[*] --> REQUESTED : rider taps Book
REQUESTED --> ACCEPTED : driver accepts
REQUESTED --> CANCELLED : no driver found / rider cancels
ACCEPTED --> IN_PROGRESS : driver arrives at pickup
IN_PROGRESS --> COMPLETED : destination reached
IN_PROGRESS --> CANCELLED : rider or driver cancels mid-trip
COMPLETED --> [*]
CANCELLED --> [*]
// Transition enforcement
public void transition(TripStatus newStatus) {
if (!isValidTransition(this.status, newStatus)) {
throw new IllegalStateException(
"Cannot transition from " + this.status + " to " + newStatus);
}
this.status = newStatus;
}
private boolean isValidTransition(TripStatus from, TripStatus to) {
return switch (from) {
case REQUESTED -> to == ACCEPTED || to == CANCELLED;
case ACCEPTED -> to == IN_PROGRESS || to == CANCELLED;
case IN_PROGRESS -> to == COMPLETED || to == CANCELLED;
default -> false;
};
}
๐ Real-World Applications: Observer Pattern: Real-Time Driver Location Updates
Riders, dispatchers, and ETAs all need to react when a driver's location changes. The Observer Pattern decouples location updates from their consumers.
// Observer interface
interface LocationObserver {
void onLocationUpdate(String driverId, Location newLocation);
}
// Subject (driver location tracker)
class DriverLocationService {
private Map<String, List<LocationObserver>> subscribers = new HashMap<>();
public void subscribe(String driverId, LocationObserver observer) {
subscribers.computeIfAbsent(driverId, k -> new ArrayList<>()).add(observer);
}
public void updateLocation(String driverId, Location loc) {
subscribers.getOrDefault(driverId, List.of())
.forEach(obs -> obs.onLocationUpdate(driverId, loc));
}
}
// Concrete observer
class RiderEtaTracker implements LocationObserver {
@Override
public void onLocationUpdate(String driverId, Location loc) {
// Recalculate ETA and push to rider's app
System.out.println("ETA updated for driver " + driverId + " at " + loc);
}
}
๐ Matching Algorithm: Finding the Nearest Available Driver
The simplest valid matching algorithm:
- Filter drivers by status = AVAILABLE.
- For each candidate, compute distance to rider pickup.
- Return the driver with minimum distance.
graph TD
A[Ride Request received โ pickup location known] --> B[Query driver index: status = AVAILABLE]
B --> C[For each candidate: compute Haversine distance to pickup]
C --> D{Any drivers within radius?}
D -->|No| E[Expand radius or notify rider of no availability]
D -->|Yes| F[Sort candidates by distance ascending]
F --> G[Assign nearest driver โ send acceptance request]
G --> H{Driver accepts?}
H -->|Yes| I[Trip transitions to ACCEPTED]
H -->|No| F
Haversine distance between two geo-coordinates:
$$d = 2R \cdot rcsin\left(\sqrt{\sin^2\!rac{\Delta\phi}{2} + \cos\phi_1\cos\phi_2\sin^2\!rac{\Delta\lambda}{2}} ight)$$
where $R = 6371$ km, $\phi$ = latitude, $\lambda$ = longitude.
class MatchingService {
public Optional<Driver> findNearest(Location pickup, List<Driver> drivers) {
return drivers.stream()
.filter(d -> d.getStatus() == DriverStatus.AVAILABLE)
.min(Comparator.comparingDouble(d -> haversine(pickup, d.getLocation())));
}
private double haversine(Location a, Location b) {
double R = 6371.0;
double dLat = Math.toRadians(b.lat() - a.lat());
double dLon = Math.toRadians(b.lon() - a.lon());
double h = Math.sin(dLat/2)*Math.sin(dLat/2)
+ Math.cos(Math.toRadians(a.lat()))
* Math.cos(Math.toRadians(b.lat()))
* Math.sin(dLon/2)*Math.sin(dLon/2);
return 2 * R * Math.asin(Math.sqrt(h));
}
}
Production extensions: geospatial index (PostGIS, S2, QuadTree) replaces brute-force scan; add driver rating, vehicle type, and preference filters.
๐ Book Ride: Request-to-Match Sequence
sequenceDiagram
participant R as Rider
participant TRS as TripRequestService
participant MS as MatchingService
participant D as Driver
participant T as Trip
R->>TRS: requestRide(pickup, dropoff)
TRS->>MS: match(request, drivers)
MS->>MS: haversine() per driver
MS-->>TRS: nearestDriver
TRS->>D: acceptRide(request)
D-->>TRS: confirmed
TRS->>T: new Trip(REQUESTED)
T->>T: transition(ACCEPTED)
TRS-->>R: Trip{status=ACCEPTED}
๐ฐ Strategy Pattern: Flexible Pricing Rules
Pricing logic must change at runtime โ flat rate, surge, subscription, etc. The Strategy Pattern lets you swap rules without modifying TripPricingEngine.
interface PricingStrategy {
double calculateFare(double distanceKm, int durationMinutes, double surgeMultiplier);
}
class StandardPricing implements PricingStrategy {
public double calculateFare(double distKm, int durMin, double surge) {
return (0.5 + 1.2 * distKm + 0.25 * durMin) * surge;
}
}
class FlatRatePricing implements PricingStrategy {
private double flatRate;
public FlatRatePricing(double rate) { this.flatRate = rate; }
public double calculateFare(double d, int t, double s) { return flatRate; }
}
class TripPricingEngine {
private PricingStrategy strategy;
public TripPricingEngine(PricingStrategy s) { this.strategy = s; }
public void setStrategy(PricingStrategy s) { this.strategy = s; }
public double quote(double km, int min, double surge) {
return strategy.calculateFare(km, min, surge);
}
}
๐งฑ OOP Pillars Applied to the Ride-Booking Domain
Every core class in this design maps directly to one of the four OOP pillars. The mapping is explicit, not accidental โ each pillar solves a specific design problem in the ride-booking context.
| Pillar | Class / Interface | What It Achieves |
| Encapsulation | Driver hides DriverStatus | External code calls acceptRide(trip) / completeRide(trip) โ it never sets status directly. Location is updated via updateLocation(Location loc), not by field access |
| Abstraction | MatchingStrategy interface | TripRequestService calls match(request, availableDrivers) without knowing whether the algorithm is nearest-driver, highest-rated, or category-based |
| Inheritance | PremiumDriver extends Driver, EvDriver extends Driver | Shared state (id, name, status, location) lives in Driver; subclasses add luxury attributes or battery/range without duplicating the base |
| Polymorphism | List<Driver> holds all subtypes; PricingStrategy implementations | drivers.forEach(d -> d.isAvailableFor(request)) works uniformly across all driver subtypes; strategy.calculate(trip, driver) dispatches to flat-rate, surge, or subscription logic at runtime |
classDiagram
class Driver {
+String id
+String name
-DriverStatus status
+updateLocation(Location loc)
+acceptRide(TripRequest req)
+completeRide(Trip trip)
+isAvailableFor(TripRequest req) bool
}
class PremiumDriver {
+String vehicleClass
+bool hasWifi
}
class EvDriver {
+double batteryPercent
+isAvailableFor(TripRequest req) bool
}
class MatchingStrategy {
<>
+match(TripRequest req, List~Driver~ available) Optional~Driver~
}
class PricingStrategy {
<>
+calculate(TripRequest req, Driver driver) Fare
}
class TripRequestService {
-MatchingStrategy matcher
-PricingStrategy pricer
+requestRide(TripRequest req) Trip
}
class Trip {
+String tripId
+TripStatus status
+transition(TripStatus next)
}
PremiumDriver --|> Driver : extends
EvDriver --|> Driver : extends
TripRequestService o-- MatchingStrategy : uses (injected)
TripRequestService o-- PricingStrategy : uses (injected)
Driver --> Trip : fulfills
Driver.status is private โ it is only mutated through named domain operations. PremiumDriver and EvDriver inherit the full base contract and extend it. TripRequestService depends only on injected abstractions โ it has no import for NearestDriverMatcher or StandardPricing.
๐ Full Domain Class Diagram
classDiagram
class TripRequest {
+Location pickup
+Location dropoff
+String riderId
}
class Trip {
+String tripId
-TripStatus status
+Driver assignedDriver
+transition(TripStatus) void
}
class Driver {
+String id
+String name
-DriverStatus status
+Location location
+acceptRide(TripRequest) void
+completeRide(Trip) void
+isAvailableFor(TripRequest) boolean
}
class PremiumDriver {
+String vehicleClass
+boolean hasWifi
}
class EvDriver {
+double batteryPercent
+isAvailableFor(TripRequest) boolean
}
class TripRequestService {
-MatchingStrategy matcher
-PricingStrategy pricer
+requestRide(TripRequest) Trip
}
class MatchingStrategy {
<>
+match(TripRequest, List~Driver~) Optional~Driver~
}
class PricingStrategy {
<>
+calculate(TripRequest, Driver) Fare
}
class LocationObserver {
<>
+onLocationUpdate(String, Location) void
}
class DriverLocationService {
-Map~String,List~ subscribers
+subscribe(String, LocationObserver) void
+updateLocation(String, Location) void
}
class RiderEtaTracker {
+onLocationUpdate(String, Location) void
}
class TripStatus {
<>
REQUESTED
ACCEPTED
IN_PROGRESS
COMPLETED
CANCELLED
}
class DriverStatus {
<>
AVAILABLE
BUSY
OFFLINE
}
PremiumDriver --|> Driver : extends
EvDriver --|> Driver : extends
TripRequestService o-- MatchingStrategy : uses (injected)
TripRequestService o-- PricingStrategy : uses (injected)
RiderEtaTracker ..|> LocationObserver : implements
DriverLocationService --> LocationObserver : notifies
Trip --> TripStatus : state
Driver --> DriverStatus : state
Trip --> Driver : assigned to
TripRequest --> Trip : becomes
โ SOLID Principles in the Ride-Booking Design
| Principle | How It Shows Up |
| SRP | Driver manages driver state; TripMatcher matches driver to request; TripFareCalculator computes fare; TripStateMachine manages the trip lifecycle โ four focused classes, each with exactly one reason to change |
| OCP | New pricing rule โ new PricingStrategy implementation; new matching rule โ new MatchingStrategy implementation. TripPricingEngine and MatchingService never change |
| LSP | PremiumDriver and EvDriver both honour the Driver.isAvailableFor(request) contract โ either subtype safely replaces a Driver reference without the caller knowing or caring |
| ISP | MatchingStrategy is a single-method interface; PricingStrategy is separate; LocationService is separate โ no fat interface forces a class to implement methods it does not need |
| DIP | TripRequestService depends on MatchingStrategy, PricingStrategy, and LocationService abstractions injected at construction โ never on NearestDriverMatcher or StandardPricing directly |
๐ Interface Contracts: The Seams That Make This Design Extensible
Every swap point in this design is a Java interface. These four contracts are the seams between components โ swapping an implementation never breaks the consumer:
// Injected into TripRequestService โ swap to change matching algorithm at runtime
interface MatchingStrategy {
Optional<Driver> match(TripRequest request, List<Driver> availableDrivers);
}
// Injected into TripRequestService โ swap to change pricing tier at runtime
interface PricingStrategy {
Fare calculate(TripRequest request, Driver driver);
}
// Injected into MatchingService โ abstracts the geospatial query provider
interface LocationService {
List<Driver> findNearby(Location center, double radiusKm);
double distanceBetween(Location a, Location b);
}
// Observer contract for trip state changes โ triggers push notifications, audit logs
interface TripObserver { // Observer Pattern for state changes
void onTripStateChanged(Trip trip, TripStatus newStatus);
}
Each interface has exactly one reason to change. A new matching algorithm, pricing tier, location backend, or notification channel is a new implementing class โ not a modification to any existing class.
โ๏ธ Trade-offs & Failure Modes: Trade-offs and Production Constraints
| Decision | Default choice | Why | Production consideration |
| Matching scope | Nearest available driver (Haversine) | Simple, correct for small fleets | Switch to geospatial index (PostGIS / S2) at scale |
| State storage | Single-node in-memory | Easy to start | Use distributed state (Redis, DB) in production |
| Observer delivery | Synchronous in-process | Simple | Use event bus / message queue for multi-service delivery |
| Pricing logic | Strategy pattern (swappable) | Good OCP compliance | Add audit log per fare calculation |
| Duplicate trips | Not handled in basic LLD | Simplified | Require idempotency key per ride request |
๐งญ Decision Guide: LLD Decision Guide for Ride Booking
| Feature needed | Pattern | Core classes |
| Real-time location push | Observer | DriverLocationService, LocationObserver |
| Pluggable pricing | Strategy | PricingStrategy, TripPricingEngine |
| Trip state safety | State machine + enum | TripStatus, transition() |
| Nearest driver lookup | Service + algorithm | MatchingService, haversine() |
๐ฏ What to Learn Next
- Strategy Design Pattern Explained
- Low-Level Design for Tic-Tac-Toe
- System Design Databases: SQL vs. NoSQL
๐ง Deep Dive: Ride Booking System Architecture
Internals
A production ride-booking system decomposes into five collaborating services, each owned by a separate team:
- Location Service โ Ingests driver GPS pings (typically every 5 seconds) via a persistent WebSocket connection. Stores current positions in Redis (TTL 60 s) and publishes location-change events to a Kafka topic
driver.location.updated. - Matching Service โ Subscribes to
driver.location.updated. Maintains an in-memory geospatial index (S2 cells or PostGIS) of available drivers. On a ride request, queries the index for candidates within a configurable radius and ranks by estimated time of arrival (ETA). - Trip Service โ Owns the trip state machine. Persists all state transitions to a relational database with an audit log. Enforces idempotency via a client-supplied
requestIdto prevent duplicate trips from network retries. - Pricing Service โ Implements the Strategy Pattern. Calculates the fare quote before trip acceptance and the final fare at completion. Stores the quote at request time to protect against surge changes mid-trip.
- Notification Service โ Consumes trip state change events and pushes real-time updates to the rider and driver via Firebase Cloud Messaging or APNs.
The key internal contract is the event bus: services communicate through Kafka events rather than direct RPC calls. This decoupling means the Matching Service can be redeployed without restarting the Trip Service.
Performance Analysis
| Bottleneck | Cause | Solution |
| Brute-force driver scan | O(n) Haversine over all available drivers | Replace with PostGIS ST_DWithin or S2 cell index โ O(log n) |
| Trip state under concurrent updates | Race condition if two drivers accept the same trip | Optimistic locking on trip.version; first writer wins |
| Location fan-out latency | Broadcasting one driver update to thousands of riders | Partition by city/region; limit subscribers to the requesting rider's trip only |
| Pricing calculation on hot path | Strategy computation blocks trip acceptance | Pre-compute fare estimate asynchronously; cache for 90 s |
At 100k concurrent trips, the Matching Service query latency must stay under 200 ms (p99). The PostGIS index reduces scan time from ~800 ms to ~12 ms on a fleet of 50,000 drivers.
Mathematical Model
The ETA estimate for a candidate driver combines two components:
$$\text{ETA}(d, r) = \frac{D_{\text{haversine}}(d_{\text{loc}}, r_{\text{pickup}})}{v_{\text{avg}}} + T_{\text{traffic}}$$
where $v{\text{avg}}$ is the rolling 10-minute average speed for that road segment (sourced from historical GPS traces) and $T{\text{traffic}}$ is a learned correction term from a gradient-boosted model trained on time-of-day and weather features.
The surge multiplier is derived from a supply/demand ratio:
$$\text{surge}(t) = \max\!\left(1.0,\; \frac{\text{active\_requests}(t)}{\text{available\_drivers}(t) \times k}\right)$$
where $k$ is a smoothing constant (typically 1.5) that prevents surge from spiking on momentary demand bursts.
โ๏ธ OOP Extension Points: Adding Features Without Breaking Existing Classes
The OCP compliance from the SOLID section has concrete product-level consequences: every new feature maps to a new class. No existing class needs to be opened, edited, or retested. Here are two examples that prove it.
Adding surge pricing โ wraps the base strategy via the Decorator Pattern, zero changes to TripPricingEngine or StandardPricing:
// Adding surge pricing: new class, zero changes to existing code
class SurgePricingStrategy implements PricingStrategy {
private final PricingStrategy base;
private final SurgeCalculator surge;
SurgePricingStrategy(PricingStrategy base, SurgeCalculator surge) {
this.base = base;
this.surge = surge;
}
@Override
public Fare calculate(Trip trip, Driver driver) {
double multiplier = surge.getMultiplier(trip.getPickup(), Instant.now());
return base.calculate(trip, driver).multiply(multiplier); // Decorator pattern
}
}
Adding EV-only drivers โ subclass overrides the availability check, zero changes to MatchingService or TripRequestService:
// Adding EV-only drivers: new subclass, zero changes to Matcher
class EvDriver extends Driver {
double batteryPercent;
@Override
public boolean isAvailableFor(TripRequest request) {
return super.isAvailableFor(request) && batteryPercent > 20
&& chargeRangeCovers(request.getPickup(), request.getDestination());
}
}
| Feature Added | OOP Mechanism | Existing Classes Changed |
| Surge pricing | SurgePricingStrategy (Decorator over PricingStrategy) | 0 |
| EV drivers | EvDriver extends Driver (Inheritance + override) | 0 |
| Premium matching | PremiumMatchingStrategy implements MatchingStrategy | 0 |
| Scheduled rides | ScheduledTripRequest extends TripRequest (Inheritance) | 0 |
Every new feature is a new class. The four OOP pillars โ Encapsulation, Abstraction, Inheritance, Polymorphism โ ensure the existing codebase is never destabilised by a new product requirement.
๐๏ธ Advanced OOP Design Considerations: Anti-Patterns to Avoid
Even with correct patterns in place, a few OOP design traps appear consistently in ride-booking LLD interviews.
| Anti-Pattern | What It Looks Like | Why It Hurts | Fix |
| Anemic Domain Model | TripService.transitionStatus(tripId, newStatus) contains all transition logic | State machine rules leak into the service; any service can corrupt state | Move transition() enforcement into Trip โ the domain object owns its invariants |
| God Class Driver | Driver handles matching, pricing, notification, and state all at once | One class with ten reasons to change; impossible to test in isolation | Split: Driver = state only; MatchingService, TripFareCalculator, DriverLocationService = separate responsibilities |
| Concrete dependency in service | TripRequestService holds a new NearestDriverMatcher() field | Impossible to swap matching algorithm or test with a mock | Inject MatchingStrategy via constructor โ the service never knows the concrete type |
| LSP violation in subclass | EvDriver.isAvailableFor() throws UnsupportedOperationException for long trips instead of returning false | Callers can't treat EvDriver as a Driver without special-casing | Override must return the same type and satisfy the parent's postconditions โ never throw where the parent returns |
The first three anti-patterns are the most common in interview submissions. A design that avoids all four โ clear domain ownership, focused classes, injected abstractions, and honest subtype contracts โ is ready for a production code review.
๐งช Implementation Walkthrough: State Machine in Practice
The most error-prone part of any LLD interview is building a correct state machine. Here is a step-by-step walkthrough for implementing the trip lifecycle with full transition enforcement.
Step 1 โ Define all valid transitions as a map:
private static final Map<TripStatus, Set<TripStatus>> VALID_TRANSITIONS = Map.of(
TripStatus.REQUESTED, Set.of(TripStatus.ACCEPTED, TripStatus.CANCELLED),
TripStatus.ACCEPTED, Set.of(TripStatus.IN_PROGRESS, TripStatus.CANCELLED),
TripStatus.IN_PROGRESS, Set.of(TripStatus.COMPLETED, TripStatus.CANCELLED)
);
Using a static map instead of a switch statement makes the valid transitions visible as data, not code. Adding a new status only requires updating the map.
Step 2 โ Enforce in the domain object, not in the service layer:
public void transition(TripStatus newStatus) {
Set<TripStatus> allowed = VALID_TRANSITIONS.getOrDefault(this.status, Set.of());
if (!allowed.contains(newStatus)) {
throw new IllegalStateException(
String.format("Trip %s: invalid transition %s โ %s", tripId, this.status, newStatus));
}
this.status = newStatus;
this.lastUpdated = Instant.now();
}
Placing this guard in the domain object (Trip) means the rule is enforced regardless of which service calls it.
Step 3 โ Test every valid and invalid path:
@Test void shouldRejectInvalidTransition() {
Trip trip = new Trip(...);
trip.transition(TripStatus.ACCEPTED);
assertThrows(IllegalStateException.class,
() -> trip.transition(TripStatus.REQUESTED)); // backward transition
}
๐ ๏ธ Spring Boot: Wiring the Ride Booking Domain into a REST API
Spring Boot is the standard Java framework for building REST APIs and service layers โ it provides @RestController for HTTP endpoints, Spring Data JPA for repository-pattern persistence, @Transactional for state-machine safety, and @Service beans for clean domain separation. Together they translate the LLD classes from this post directly into a deployable microservice.
Spring solves the production concerns from the trade-offs section: @Transactional + @Lock(PESSIMISTIC_WRITE) prevents two drivers from accepting the same trip, Spring Data handles the repository pattern, and @RestController exposes the domain over HTTP with minimal boilerplate.
import jakarta.persistence.*;
import org.springframework.data.jpa.repository.*;
import org.springframework.transaction.annotation.*;
import org.springframework.web.bind.annotation.*;
import org.springframework.stereotype.*;
import java.util.*;
// โโ Domain entities โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
@Entity
public class Trip {
@Id @GeneratedValue String tripId;
String riderId;
String driverId;
@Enumerated(EnumType.STRING) TripStatus status = TripStatus.REQUESTED;
double pickupLat, pickupLon, destLat, destLon;
@Version int version; // optimistic lock: guards concurrent driver acceptance
public void transition(TripStatus next) {
Set<TripStatus> allowed = Map.of(
TripStatus.REQUESTED, Set.of(TripStatus.ACCEPTED, TripStatus.CANCELLED),
TripStatus.ACCEPTED, Set.of(TripStatus.IN_PROGRESS, TripStatus.CANCELLED),
TripStatus.IN_PROGRESS, Set.of(TripStatus.COMPLETED, TripStatus.CANCELLED)
).getOrDefault(this.status, Set.of());
if (!allowed.contains(next))
throw new IllegalStateException("Invalid: " + status + " โ " + next);
this.status = next;
}
}
@Entity
public class Driver {
@Id String driverId;
String name;
@Enumerated(EnumType.STRING) DriverStatus status = DriverStatus.AVAILABLE;
double lat, lon;
}
// โโ Repository layer (Spring Data JPA) โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
public interface TripRepository extends JpaRepository<Trip, String> {
List<Trip> findByStatus(TripStatus status);
}
public interface DriverRepository extends JpaRepository<Driver, String> {
List<Driver> findByStatus(DriverStatus status);
}
// โโ Matching + Pricing services โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
@Service
public class MatchingService {
private final DriverRepository driverRepo;
public MatchingService(DriverRepository r) { this.driverRepo = r; }
public Optional<Driver> findNearest(double lat, double lon) {
return driverRepo.findByStatus(DriverStatus.AVAILABLE).stream()
.min(Comparator.comparingDouble(d -> haversine(lat, lon, d.lat, d.lon)));
}
private double haversine(double lat1, double lon1, double lat2, double lon2) {
double dLat = Math.toRadians(lat2 - lat1), dLon = Math.toRadians(lon2 - lon1);
double a = Math.sin(dLat/2)*Math.sin(dLat/2)
+ Math.cos(Math.toRadians(lat1))*Math.cos(Math.toRadians(lat2))
* Math.sin(dLon/2)*Math.sin(dLon/2);
return 2 * 6371 * Math.asin(Math.sqrt(a));
}
}
// โโ Trip service: state machine + @Transactional safety โโโโโโโโโโโโโโโโโโโโโโ
@Service
public class TripService {
private final TripRepository tripRepo;
private final MatchingService matcher;
public TripService(TripRepository tripRepo, MatchingService matcher) {
this.tripRepo = tripRepo; this.matcher = matcher;
}
@Transactional
public Trip bookRide(String riderId, double pLat, double pLon, double dLat, double dLon) {
Driver nearest = matcher.findNearest(pLat, pLon)
.orElseThrow(() -> new RuntimeException("No drivers available"));
Trip trip = new Trip();
trip.tripId = UUID.randomUUID().toString();
trip.riderId = riderId; trip.driverId = nearest.driverId;
trip.pickupLat = pLat; trip.pickupLon = pLon;
trip.destLat = dLat; trip.destLon = dLon;
return tripRepo.save(trip);
}
@Transactional
public Trip updateStatus(String tripId, TripStatus newStatus) {
Trip trip = tripRepo.findById(tripId)
.orElseThrow(() -> new RuntimeException("Trip not found: " + tripId));
trip.transition(newStatus); // enforces state machine rules
return tripRepo.save(trip); // @Version check prevents concurrent conflicts
}
}
// โโ REST controller โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
@RestController
@RequestMapping("/api/trips")
public class TripController {
private final TripService tripService;
public TripController(TripService tripService) { this.tripService = tripService; }
// POST /api/trips/book?riderId=r1&pLat=37.77&pLon=-122.4&dLat=37.79&dLon=-122.39
@PostMapping("/book")
public Trip book(@RequestParam String riderId,
@RequestParam double pLat, @RequestParam double pLon,
@RequestParam double dLat, @RequestParam double dLon) {
return tripService.bookRide(riderId, pLat, pLon, dLat, dLon);
}
// PATCH /api/trips/{tripId}/status?status=ACCEPTED
@PatchMapping("/{tripId}/status")
public Trip updateStatus(@PathVariable String tripId,
@RequestParam TripStatus status) {
return tripService.updateStatus(tripId, status);
}
}
The @Version field on Trip guards against two drivers simultaneously accepting the same request โ Spring Data JPA throws OptimisticLockException on the second committer, matching the "first writer wins" semantics described in the performance analysis section.
For a full deep-dive on Spring Boot for LLD and domain-driven design, a dedicated follow-up post is planned.
๐ Lessons from Building Ride-Booking Systems
Lesson 1 โ State machines prevent silent data corruption. Without explicit transition enforcement, a network partition could leave a trip stuck in ACCEPTED while the driver is already completing a different trip. State guards surface these bugs immediately rather than silently producing corrupt data.
Lesson 2 โ The Observer Pattern handles fan-out, not fan-in. Location updates flow from one driver to many observers (ETA tracker, dispatcher, map display). Using the Observer Pattern here is correct. The reverse โ many events converging to one aggregator โ is better handled by a message queue.
Lesson 3 โ Pricing immutability is a trust requirement. The fare quoted at request time must be stored and honored at payment. Recalculating at completion would allow surge pricing to increase the charge mid-trip without the rider's consent. Store the quote as an immutable snapshot.
Lesson 4 โ Geospatial queries are the performance bottleneck. Almost every LLD candidate designs a clean state machine but overlooks that the findNearest() scan is O(n). Mentioning the PostGIS/S2 index upgrade โ even without implementation details โ signals production awareness.
Lesson 5 โ Idempotency is non-negotiable in mobile contexts. Mobile networks are unreliable. Every mutating endpoint must handle duplicate requests gracefully. The clientRequestId pattern costs almost nothing to implement and prevents a class of duplicate-booking bugs that are extremely hard to debug in production.
๐ TLDR: Summary & Key Takeaways
- A ride-booking system is composed of three collaborating concerns: location tracking, driver matching, and pricing.
- The Observer Pattern decouples location update producers from consumers (ETA trackers, dispatchers).
- State machine enums guard trip transitions โ invalid transitions throw rather than silently corrupt state.
- Haversine distance gives correct nearest-driver ranking; replace with a geospatial index at scale.
- The Strategy Pattern makes pricing rules swappable at runtime without touching
TripPricingEngine. - All four OOP pillars are present:
Driverencapsulates its state machine, interfaces abstract matching and pricing, subtypes extend without breaking callers, and polymorphism letsList<Driver>hold all driver variants. - SOLID compliance means adding a new feature (surge pricing, EV drivers, scheduled rides) requires only a new class โ zero modifications to any existing class.
๐ Practice Quiz
What problem does the Observer Pattern solve in a ride-booking system?
- A) Dynamic pricing based on demand
- B) Decoupling location update producers from multiple downstream consumers
- C) Matching the nearest driver to a rider
- D) Enforcing valid trip state transitions
Correct Answer: B โ The Observer Pattern allows the
DriverLocationServiceto notify ETA trackers, dispatchers, and map displays without knowing anything about them. Adding a new observer requires zero changes to the location service.What is the correct behavior when an invalid trip state transition is attempted?
- A) Silently transition to the requested state
- B) Log a warning and continue
- C) Throw an exception and leave state unchanged
- D) Reset the trip to REQUESTED
Correct Answer: C โ Invalid transitions must fail loudly. Silent corruption is far harder to debug than an explicit exception. The
IllegalStateExceptionsurfaces the bug at its source.What is the primary limitation of brute-force Haversine matching at production scale?
- A) It cannot handle surge pricing
- B) Scanning all available drivers is O(n) โ too slow for large fleets
- C) It doesn't account for driver rating
- D) It requires floating-point arithmetic
Correct Answer: B โ With 50,000 available drivers, a brute-force scan takes ~800 ms. A PostGIS spatial index reduces this to ~12 ms by only examining drivers in the relevant geographic cell.
Which design pattern makes the pricing engine extensible without modifying
TripPricingEngine?- A) Observer Pattern
- B) State Pattern
- C) Strategy Pattern
- D) Factory Pattern
Correct Answer: C โ The Strategy Pattern extracts each pricing algorithm into its own class. Adding a
SubscriptionPricingorAirportFlatRatePricingrequires a new class only;TripPricingEnginenever changes.Open-ended: A driver's phone loses connectivity for 45 seconds during a trip. Describe how the system should handle the stale driver location and what impact this has on the trip state machine.
This is an open-ended design question with no single correct answer. A strong answer covers: (1) heartbeat TTL โ after 30 s without a ping, the driver's location is marked stale; (2) the trip remains IN_PROGRESS because loss of location โ cancellation; (3) the ETA tracker stops updating until connectivity resumes; (4) if the driver reconnects, location resumes normally; (5) if connectivity is lost for longer (e.g., 5 min), a support flag is raised but the trip is not auto-cancelled to avoid penalizing drivers in tunnels.
๐ Related Posts
- Strategy Design Pattern Explained
- LLD for Parking Lot System
- LLD for LRU Cache: Designing a High-Performance Cache

Written by
Abstract Algorithms
@abstractalgorithms
More Posts

Adapting to Virtual Threads for Spring Developers
TLDR: Platform threads (one OS thread per request) max out at a few hundred concurrent I/O-bound requests. Virtual threads (JDK 21+) allow millions โ with zero I/O-blocking cost. Spring Boot 3.2 enables them with a single property. Avoid synchronized...

Java 8 to Java 25: How Java Evolved from Boilerplate to a Modern Language
TLDR: Java went from the most verbose mainstream language to one of the most expressive. Lambdas killed anonymous inner classes. Records killed POJOs. Virtual threads killed thread pools for I/O work.
Data Anomalies in Distributed Systems: Split Brain, Clock Skew, Stale Reads, and More
TLDR: Distributed systems produce anomalies not because the code is buggy โ but because physics makes it impossible to be perfectly consistent, available, and partition-tolerant simultaneously. Split brain, stale reads, clock skew, causality violatio...
Sharding Approaches in SQL and NoSQL: Range, Hash, and Directory-Based Strategies Compared
TLDR: Sharding splits your database across multiple physical nodes so no single machine carries all the data or absorbs all the writes. The strategy you choose โ range, hash, consistent hashing, or directory โ determines whether range queries stay ch...
