Search…
Low Level Design · Part 10

Designing a ride-sharing model

In this series (15 parts)
  1. Introduction to low level design
  2. SOLID principles
  3. Design patterns: Creational
  4. Design patterns: Structural
  5. Design patterns: Behavioral
  6. Designing a parking lot
  7. Designing a library management system
  8. Designing an elevator system
  9. Designing a hotel booking system
  10. Designing a ride-sharing model
  11. Designing a rate limiter
  12. Designing a logging framework
  13. Designing a notification system
  14. API design and contract-first development
  15. Data modeling for system design

Ride-sharing systems like Uber and Lyft are deceptively simple on the surface: a rider requests a ride, a driver accepts, they meet, travel, and pay. But underneath that flow sits a rich domain model with a multi-state trip lifecycle, a driver matching interface that must be swappable, and a pricing engine that changes behavior based on demand. This article focuses on the object model, not the distributed systems layer. Think of it as the domain core that a larger system wraps.

Requirements

Functional:

  1. Riders can request a ride by providing pickup and drop-off locations.
  2. The system matches the rider with a nearby available driver.
  3. The driver can accept or decline the request. On decline, the system tries the next driver.
  4. Once accepted, the system tracks the trip through pickup, in-progress, and completion states.
  5. The fare is calculated at trip completion based on distance, time, and a pricing strategy.
  6. Both rider and driver can cancel before pickup. Cancellation after pickup follows a penalty policy.
  7. Riders and drivers can rate each other after the trip.

Non-functional:

  • The matching algorithm must be pluggable (nearest driver, highest-rated, etc.).
  • The pricing strategy must support surge pricing without modifying existing code.
  • Trip state transitions must be enforced strictly.

Entities

EntityRole
RiderRequests rides, pays fares
DriverAccepts rides, has a vehicle and location
TripCentral aggregate linking rider, driver, and fare
LocationLatitude/longitude pair
VehicleDriver’s car with type and license plate
FareComputed cost of a trip
RatingPost-trip feedback from rider or driver

Class diagram

classDiagram
  class Rider {
      -String riderId
      -String name
      -String phone
      -double rating
      +requestRide(Location pickup, Location dropoff) Trip
      +cancelTrip(Trip) void
      +rateDriver(Trip, int score) void
  }

  class Driver {
      -String driverId
      -String name
      -String phone
      -double rating
      -Vehicle vehicle
      -Location currentLocation
      -DriverStatus status
      +acceptTrip(Trip) void
      +declineTrip(Trip) void
      +startTrip(Trip) void
      +completeTrip(Trip) void
      +updateLocation(Location) void
  }

  class Vehicle {
      -String licensePlate
      -String model
      -VehicleType type
      -int capacity
  }

  class Trip {
      -String tripId
      -Rider rider
      -Driver driver
      -Location pickup
      -Location dropoff
      -TripStatus status
      -LocalDateTime requestTime
      -LocalDateTime pickupTime
      -LocalDateTime dropoffTime
      -Fare fare
      +accept(Driver) void
      +startRide() void
      +complete() void
      +cancel(String reason) void
  }

  class Location {
      -double latitude
      -double longitude
      +distanceTo(Location other) double
  }

  class Fare {
      -double baseFare
      -double distanceCharge
      -double timeCharge
      -double surgeMultiplier
      -double totalAmount
  }

  class Rating {
      -String ratingId
      -Trip trip
      -String fromUserId
      -String toUserId
      -int score
      -String comment
  }

  class DriverStatus {
      <<enumeration>>
      AVAILABLE
      ON_TRIP
      OFFLINE
  }

  class TripStatus {
      <<enumeration>>
      REQUESTED
      DRIVER_ASSIGNED
      DRIVER_EN_ROUTE
      ARRIVED
      IN_PROGRESS
      COMPLETED
      CANCELLED
  }

  class VehicleType {
      <<enumeration>>
      ECONOMY
      PREMIUM
      SUV
      BIKE
  }

  class DriverMatchingStrategy {
      <<interface>>
      +findDriver(Location pickup, List~Driver~ available) Driver
  }

  class NearestDriverStrategy {
      +findDriver(Location pickup, List~Driver~ available) Driver
  }

  class HighestRatedStrategy {
      +findDriver(Location pickup, List~Driver~ available) Driver
  }

  class PricingStrategy {
      <<interface>>
      +calculateFare(Trip trip) Fare
  }

  class StandardPricing {
      +calculateFare(Trip trip) Fare
  }

  class SurgePricing {
      -double surgeMultiplier
      +calculateFare(Trip trip) Fare
  }

  Rider "1" --> "many" Trip
  Driver "1" --> "1" Vehicle
  Driver "1" --> "many" Trip
  Trip "1" --> "1" Location : pickup
  Trip "1" --> "1" Location : dropoff
  Trip "1" --> "0..1" Fare
  Trip "1" --> "0..2" Rating
  Driver --> DriverStatus
  Trip --> TripStatus
  Vehicle --> VehicleType
  DriverMatchingStrategy <|.. NearestDriverStrategy
  DriverMatchingStrategy <|.. HighestRatedStrategy
  PricingStrategy <|.. StandardPricing
  PricingStrategy <|.. SurgePricing

Class diagram for the ride-sharing domain model.

Trip state machine

The trip lifecycle is the most important piece to get right. Every action in the system maps to a state transition, and invalid transitions must be rejected.

stateDiagram-v2
  [*] --> Requested
  Requested --> DriverAssigned : driver accepts
  Requested --> Cancelled : rider cancels
  Requested --> Cancelled : no driver found (timeout)
  DriverAssigned --> DriverEnRoute : driver starts navigation
  DriverAssigned --> Cancelled : rider cancels
  DriverAssigned --> Requested : driver cancels (re-match)
  DriverEnRoute --> Arrived : driver at pickup
  DriverEnRoute --> Cancelled : rider cancels (with penalty)
  Arrived --> InProgress : rider picked up
  Arrived --> Cancelled : rider no-show (timeout)
  InProgress --> Completed : arrived at dropoff
  Completed --> [*]
  Cancelled --> [*]

State diagram for a trip’s lifecycle.

Enforce transitions with a guard method:

public void accept(Driver driver) {
    if (status != TripStatus.REQUESTED) {
        throw new IllegalStateException(
            "Cannot accept trip in state: " + status);
    }
    this.driver = driver;
    this.status = TripStatus.DRIVER_ASSIGNED;
    driver.setStatus(DriverStatus.ON_TRIP);
}

public void startRide() {
    if (status != TripStatus.ARRIVED) {
        throw new IllegalStateException(
            "Cannot start ride in state: " + status);
    }
    this.pickupTime = LocalDateTime.now();
    this.status = TripStatus.IN_PROGRESS;
}

public void complete() {
    if (status != TripStatus.IN_PROGRESS) {
        throw new IllegalStateException(
            "Cannot complete trip in state: " + status);
    }
    this.dropoffTime = LocalDateTime.now();
    this.status = TripStatus.COMPLETED;
    driver.setStatus(DriverStatus.AVAILABLE);
}

Driver matching

The matching interface follows the Strategy pattern, letting you swap algorithms without changing the trip request flow.

Nearest driver: Find the available driver closest to the pickup location.

public class NearestDriverStrategy implements DriverMatchingStrategy {
    public Driver findDriver(Location pickup, List<Driver> available) {
        return available.stream()
            .filter(d -> d.getStatus() == DriverStatus.AVAILABLE)
            .min(Comparator.comparingDouble(d ->
                d.getCurrentLocation().distanceTo(pickup)))
            .orElse(null);
    }
}

Highest-rated within radius: Only consider drivers within a 5 km radius, then pick the highest-rated one. This balances wait time with ride quality.

public class HighestRatedStrategy implements DriverMatchingStrategy {
    private static final double MAX_RADIUS_KM = 5.0;

    public Driver findDriver(Location pickup, List<Driver> available) {
        return available.stream()
            .filter(d -> d.getStatus() == DriverStatus.AVAILABLE)
            .filter(d -> d.getCurrentLocation().distanceTo(pickup) <= MAX_RADIUS_KM)
            .max(Comparator.comparingDouble(Driver::getRating))
            .orElse(null);
    }
}

When a match fails (returns null), the RideService can retry with a broader strategy or notify the rider that no drivers are available.

Location and distance

The Location class wraps latitude and longitude and provides a distance method using the Haversine formula:

public class Location {
    private final double latitude;
    private final double longitude;

    public double distanceTo(Location other) {
        double R = 6371.0; // Earth radius in km
        double dLat = Math.toRadians(other.latitude - this.latitude);
        double dLon = Math.toRadians(other.longitude - this.longitude);
        double a = Math.sin(dLat / 2) * Math.sin(dLat / 2)
                 + Math.cos(Math.toRadians(this.latitude))
                 * Math.cos(Math.toRadians(other.latitude))
                 * Math.sin(dLon / 2) * Math.sin(dLon / 2);
        double c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a));
        return R * c;
    }
}

For the LLD interview, showing that you know the Haversine formula exists is enough. In production, you would use a spatial index (like an R-tree or a geohash grid) for efficient nearest-driver queries, but that belongs to the system design layer.

Pricing strategy

Fare calculation uses another Strategy implementation. The base formula:

fare = baseFare + (distanceRate * distance) + (timeRate * minutes)
totalFare = fare * surgeMultiplier
public class StandardPricing implements PricingStrategy {
    private final double baseFare;
    private final double distanceRate;
    private final double timeRate;

    public Fare calculateFare(Trip trip) {
        double distance = trip.getPickup().distanceTo(trip.getDropoff());
        long minutes = ChronoUnit.MINUTES.between(
            trip.getPickupTime(), trip.getDropoffTime());

        double distanceCharge = distanceRate * distance;
        double timeCharge = timeRate * minutes;
        double total = baseFare + distanceCharge + timeCharge;

        return new Fare(baseFare, distanceCharge, timeCharge, 1.0, total);
    }
}

public class SurgePricing implements PricingStrategy {
    private final StandardPricing base;
    private final double surgeMultiplier;

    public Fare calculateFare(Trip trip) {
        Fare baseFare = base.calculateFare(trip);
        double total = baseFare.getTotalAmount() * surgeMultiplier;
        return new Fare(baseFare.getBaseFare(), baseFare.getDistanceCharge(),
                       baseFare.getTimeCharge(), surgeMultiplier, total);
    }
}

SurgePricing wraps StandardPricing and applies a multiplier. This is the Decorator pattern layered on top of Strategy. Adding a new pricing mode (flat rate, subscription discount) means writing a new class, not modifying existing ones.

Cancellation policy

Cancellations have different consequences depending on the trip state:

StateRider cancelsDriver cancels
REQUESTEDFree, no penaltyN/A
DRIVER_ASSIGNEDFree if within 2 minutesRe-match to next driver
DRIVER_EN_ROUTECancellation fee appliesPenalty on driver rating
ARRIVEDNo-show fee after timeoutNot allowed
IN_PROGRESSNot allowedNot allowed
public void cancel(String reason) {
    if (status == TripStatus.IN_PROGRESS || status == TripStatus.COMPLETED) {
        throw new IllegalStateException("Cannot cancel trip in state: " + status);
    }
    this.status = TripStatus.CANCELLED;
    if (driver != null) {
        driver.setStatus(DriverStatus.AVAILABLE);
    }
}

The fee logic lives in a CancellationPolicy class, not inside Trip. This keeps Trip focused on state transitions.

Rating system

After a completed trip, both rider and driver can submit a rating. Ratings are simple value objects:

public class Rating {
    private final String ratingId;
    private final Trip trip;
    private final String fromUserId;
    private final String toUserId;
    private final int score; // 1 to 5
    private final String comment;
}

The aggregate rating for a driver (or rider) is a running average. Update it incrementally:

newAverage = ((oldAverage * totalRatings) + newScore) / (totalRatings + 1)

This avoids recomputing the average from all historical ratings on every new submission.

Extensibility

ChangeImpact
Add ride poolingNew Pool entity, modified matching strategy
Add scheduled ridesNew ScheduledTrip with future pickup time
Add driver incentivesNew IncentiveRule applied during fare split
Add ETA estimationNew ETAService using routing API

What comes next

This article concludes the foundational LLD series. Each system we designed, from the parking lot to ride-sharing, exercised different design muscles: entity modeling, state machines, concurrency, and strategy selection. The next step is to apply these patterns to larger case studies where multiple subsystems interact.

Start typing to search across all content
navigate Enter open Esc close