Designing a ride-sharing model
In this series (15 parts)
- Introduction to low level design
- SOLID principles
- Design patterns: Creational
- Design patterns: Structural
- Design patterns: Behavioral
- Designing a parking lot
- Designing a library management system
- Designing an elevator system
- Designing a hotel booking system
- Designing a ride-sharing model
- Designing a rate limiter
- Designing a logging framework
- Designing a notification system
- API design and contract-first development
- 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:
- Riders can request a ride by providing pickup and drop-off locations.
- The system matches the rider with a nearby available driver.
- The driver can accept or decline the request. On decline, the system tries the next driver.
- Once accepted, the system tracks the trip through pickup, in-progress, and completion states.
- The fare is calculated at trip completion based on distance, time, and a pricing strategy.
- Both rider and driver can cancel before pickup. Cancellation after pickup follows a penalty policy.
- 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
| Entity | Role |
|---|---|
| Rider | Requests rides, pays fares |
| Driver | Accepts rides, has a vehicle and location |
| Trip | Central aggregate linking rider, driver, and fare |
| Location | Latitude/longitude pair |
| Vehicle | Driver’s car with type and license plate |
| Fare | Computed cost of a trip |
| Rating | Post-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:
| State | Rider cancels | Driver cancels |
|---|---|---|
REQUESTED | Free, no penalty | N/A |
DRIVER_ASSIGNED | Free if within 2 minutes | Re-match to next driver |
DRIVER_EN_ROUTE | Cancellation fee applies | Penalty on driver rating |
ARRIVED | No-show fee after timeout | Not allowed |
IN_PROGRESS | Not allowed | Not 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
| Change | Impact |
|---|---|
| Add ride pooling | New Pool entity, modified matching strategy |
| Add scheduled rides | New ScheduledTrip with future pickup time |
| Add driver incentives | New IncentiveRule applied during fare split |
| Add ETA estimation | New 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.