All Posts

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 AlgorithmsAbstract Algorithms
ยทยท21 min read
Cover Image for LLD for Ride Booking App: Designing Uber/Lyft
Share
AI Share on X / Twitter
AI Share on LinkedIn
Copy link

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:

  1. Which drivers are available near the pickup point?
  2. Which one is closest?
  3. What price should be quoted?
  4. 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:

  1. Filter drivers by status = AVAILABLE.
  2. For each candidate, compute distance to rider pickup.
  3. 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.

PillarClass / InterfaceWhat It Achieves
EncapsulationDriver hides DriverStatusExternal code calls acceptRide(trip) / completeRide(trip) โ€” it never sets status directly. Location is updated via updateLocation(Location loc), not by field access
AbstractionMatchingStrategy interfaceTripRequestService calls match(request, availableDrivers) without knowing whether the algorithm is nearest-driver, highest-rated, or category-based
InheritancePremiumDriver extends Driver, EvDriver extends DriverShared state (id, name, status, location) lives in Driver; subclasses add luxury attributes or battery/range without duplicating the base
PolymorphismList<Driver> holds all subtypes; PricingStrategy implementationsdrivers.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

PrincipleHow It Shows Up
SRPDriver 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
OCPNew pricing rule โ†’ new PricingStrategy implementation; new matching rule โ†’ new MatchingStrategy implementation. TripPricingEngine and MatchingService never change
LSPPremiumDriver and EvDriver both honour the Driver.isAvailableFor(request) contract โ€” either subtype safely replaces a Driver reference without the caller knowing or caring
ISPMatchingStrategy is a single-method interface; PricingStrategy is separate; LocationService is separate โ€” no fat interface forces a class to implement methods it does not need
DIPTripRequestService 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

DecisionDefault choiceWhyProduction consideration
Matching scopeNearest available driver (Haversine)Simple, correct for small fleetsSwitch to geospatial index (PostGIS / S2) at scale
State storageSingle-node in-memoryEasy to startUse distributed state (Redis, DB) in production
Observer deliverySynchronous in-processSimpleUse event bus / message queue for multi-service delivery
Pricing logicStrategy pattern (swappable)Good OCP complianceAdd audit log per fare calculation
Duplicate tripsNot handled in basic LLDSimplifiedRequire idempotency key per ride request

๐Ÿงญ Decision Guide: LLD Decision Guide for Ride Booking

Feature neededPatternCore classes
Real-time location pushObserverDriverLocationService, LocationObserver
Pluggable pricingStrategyPricingStrategy, TripPricingEngine
Trip state safetyState machine + enumTripStatus, transition()
Nearest driver lookupService + algorithmMatchingService, haversine()

๐ŸŽฏ What to Learn Next


๐Ÿง  Deep Dive: Ride Booking System Architecture

Internals

A production ride-booking system decomposes into five collaborating services, each owned by a separate team:

  1. 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.
  2. 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).
  3. 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 requestId to prevent duplicate trips from network retries.
  4. 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.
  5. 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

BottleneckCauseSolution
Brute-force driver scanO(n) Haversine over all available driversReplace with PostGIS ST_DWithin or S2 cell index โ€” O(log n)
Trip state under concurrent updatesRace condition if two drivers accept the same tripOptimistic locking on trip.version; first writer wins
Location fan-out latencyBroadcasting one driver update to thousands of ridersPartition by city/region; limit subscribers to the requesting rider's trip only
Pricing calculation on hot pathStrategy computation blocks trip acceptancePre-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 AddedOOP MechanismExisting Classes Changed
Surge pricingSurgePricingStrategy (Decorator over PricingStrategy)0
EV driversEvDriver extends Driver (Inheritance + override)0
Premium matchingPremiumMatchingStrategy implements MatchingStrategy0
Scheduled ridesScheduledTripRequest 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-PatternWhat It Looks LikeWhy It HurtsFix
Anemic Domain ModelTripService.transitionStatus(tripId, newStatus) contains all transition logicState machine rules leak into the service; any service can corrupt stateMove transition() enforcement into Trip โ€” the domain object owns its invariants
God Class DriverDriver handles matching, pricing, notification, and state all at onceOne class with ten reasons to change; impossible to test in isolationSplit: Driver = state only; MatchingService, TripFareCalculator, DriverLocationService = separate responsibilities
Concrete dependency in serviceTripRequestService holds a new NearestDriverMatcher() fieldImpossible to swap matching algorithm or test with a mockInject MatchingStrategy via constructor โ€” the service never knows the concrete type
LSP violation in subclassEvDriver.isAvailableFor() throws UnsupportedOperationException for long trips instead of returning falseCallers can't treat EvDriver as a Driver without special-casingOverride 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: Driver encapsulates its state machine, interfaces abstract matching and pricing, subtypes extend without breaking callers, and polymorphism lets List<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

  1. 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 DriverLocationService to notify ETA trackers, dispatchers, and map displays without knowing anything about them. Adding a new observer requires zero changes to the location service.

  2. 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 IllegalStateException surfaces the bug at its source.

  3. 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.

  4. 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 SubscriptionPricing or AirportFlatRatePricing requires a new class only; TripPricingEngine never changes.

  5. 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.



Abstract Algorithms

Written by

Abstract Algorithms

@abstractalgorithms