Skip to main content

Ride Hailing

This looks like food-delivery.mdx at first glance - matching, a lifecycle, a pricing rule - but the pricing rule here is the interesting twist: the price isn't fixed at request time, it depends on demand right now, which means quoting a ride and requesting one can't be the same step.

Requirements

Functional

  • A Rider requests a Ride from a pickup to a drop-off location; the system matches it to the nearest available Driver.
  • The fare is computed from distance and a surge multiplier that reflects current demand in that area.
  • A ride moves through a lifecycle: requested, driver assigned, in progress, completed (or cancelled before a driver arrives).
  • A cancelled ride releases its driver back to available without charging a fare.

Non-functional

  • Driver matching must be swappable (nearest-available today, a rating-weighted match tomorrow) without changing how a ride is requested.
  • Surge pricing must be swappable independently of matching - a busy area might have plenty of drivers or none, and the two concerns shouldn't be coupled into one class.

Design

Matching and pricing are two separate strategies, not one combined "figure out the ride" method - DriverMatchingStrategy only ever answers "which driver," and PricingStrategy only ever answers "how much," and Ride calls each exactly once at the point in its lifecycle where that answer is needed. That separation is the whole point: a city can run a new surge algorithm without touching who gets matched, and vice versa.

RiderRideMatchingStrategyPricingStrategyDrivernew Ride(pickup, dropoff)1match(drivers, pickup)2quote(distance, surge)3markBusy()4transitionTo(COMPLETED)5
  1. 1A ride starts in REQUESTED with no driver and no fare yet.
  2. 2Matching is asked for a driver, nothing about price.
  3. 3Pricing is asked separately, nothing about which driver.
  4. 4The chosen driver updates its own availability, same as DeliveryPartner in food-delivery.mdx.
  5. 5The locked-in fare from assignment time is what the rider is charged, unaffected by any surge change since.

Like food-delivery.mdx, Ride.status only advances through one guarded method - but unlike that page, cancellation here is a first-class transition available from more than one state, since a rider can back out any time before the driver actually arrives.

Class diagram

«interface»DriverMatchingStrategy+ match(drivers, pickup): Driver«interface»PricingStrategy+ quote(distanceKm, surgeMultiplier): doubleRide- rider: Rider- pickup: Location- dropoff: Location- status: RideStatus- driver: Driver- fare: double+ requestDriver(matching, drivers)+ transitionTo(status)Rider- id: string- name: stringDriver- id: string- vehicle: Vehicle- location: Location- available: bool+ markBusy()+ markAvailable()Vehicle- plate: string- category: VehicleCategoryNearestDriverStrategy+ match(drivers, pickup): DriverSurgePricingStrategy+ quote(distanceKm, surgeMultiplier): double
implementsuses
Ride delegates matching to DriverMatchingStrategy and fare math to PricingStrategy - two independent axes of change.

Code

import java.util.*;
 
enum RideStatus { REQUESTED, DRIVER_ASSIGNED, IN_PROGRESS, COMPLETED, CANCELLED }
enum VehicleCategory { ECONOMY, XL, PREMIUM }
 
class Location {
final double lat;
final double lng;
 
Location(double lat, double lng) {
this.lat = lat;
this.lng = lng;
}
 
double distanceKm(Location other) {
return Math.hypot(lat - other.lat, lng - other.lng) * 111.0;
}
}
 
class Vehicle {
final String plate;
final VehicleCategory category;
 
Vehicle(String plate, VehicleCategory category) {
this.plate = plate;
this.category = category;
}
}
 
class Driver {
final String id;
final Vehicle vehicle;
Location location;
private boolean available = true;
 
Driver(String id, Vehicle vehicle, Location location) {
this.id = id;
this.vehicle = vehicle;
this.location = location;
}
 
boolean isAvailable() {
return available;
}
 
void markBusy() {
available = false;
}
 
void markAvailable() {
available = true;
}
}
 
class Rider {
final String id;
final String name;
 
Rider(String id, String name) {
this.id = id;
this.name = name;
}
}
 
interface DriverMatchingStrategy {
Optional<Driver> match(List<Driver> drivers, Location pickup);
}
 
class NearestDriverStrategy implements DriverMatchingStrategy {
public Optional<Driver> match(List<Driver> drivers, Location pickup) {
return drivers.stream()
.filter(Driver::isAvailable)
.min(Comparator.comparingDouble(d -> d.location.distanceKm(pickup)));
}
}
 
interface PricingStrategy {
double quote(double distanceKm, double surgeMultiplier);
}
 
class SurgePricingStrategy implements PricingStrategy {
private final double baseFare;
private final double perKm;
 
SurgePricingStrategy(double baseFare, double perKm) {
this.baseFare = baseFare;
this.perKm = perKm;
}
 
public double quote(double distanceKm, double surgeMultiplier) {
return (baseFare + perKm * distanceKm) * surgeMultiplier;
}
}
 
class Ride {
private static final Map<RideStatus, Set<RideStatus>> ALLOWED_NEXT = Map.of(
RideStatus.REQUESTED, Set.of(RideStatus.DRIVER_ASSIGNED, RideStatus.CANCELLED),
RideStatus.DRIVER_ASSIGNED, Set.of(RideStatus.IN_PROGRESS, RideStatus.CANCELLED),
RideStatus.IN_PROGRESS, Set.of(RideStatus.COMPLETED),
RideStatus.COMPLETED, Set.of(),
RideStatus.CANCELLED, Set.of()
);
 
final Rider rider;
final Location pickup;
final Location dropoff;
private RideStatus status = RideStatus.REQUESTED;
private Driver driver;
private double fare;
 
Ride(Rider rider, Location pickup, Location dropoff) {
this.rider = rider;
this.pickup = pickup;
this.dropoff = dropoff;
}
 
void requestDriver(DriverMatchingStrategy matching, PricingStrategy pricing,
List<Driver> drivers, double surgeMultiplier) {
driver = matching.match(drivers, pickup)
.orElseThrow(() -> new IllegalStateException("No driver available"));
driver.markBusy();
fare = pricing.quote(pickup.distanceKm(dropoff), surgeMultiplier);
transitionTo(RideStatus.DRIVER_ASSIGNED);
}
 
void transitionTo(RideStatus next) {
if (!ALLOWED_NEXT.get(status).contains(next)) {
throw new IllegalStateException("Cannot move from " + status + " to " + next);
}
status = next;
if (next == RideStatus.CANCELLED && driver != null) {
driver.markAvailable();
}
}
 
double fare() {
return fare;
}
 
RideStatus status() {
return status;
}
}

Design decisions

  • Matching and pricing are two separate strategy interfaces, not one. They change for completely different reasons and on different schedules - ops tunes matching for wait times, finance tunes pricing for revenue - so bundling them into one MatchAndPriceStrategy would force every pricing experiment to also touch matching code, and vice versa, for no shared reason.
  • The fare is computed once, at driver assignment, and stored on the Ride - not recomputed at completion. Surge multipliers move by the minute; if fare were calculated from the surge level at drop-off instead of pickup, a rider could watch their price change mid-ride for reasons that have nothing to do with their trip. Locking it in at assignment is what makes the price the rider agreed to the price they pay.
  • Cancellation is reachable from REQUESTED and DRIVER_ASSIGNED, not just one state. A rider backing out before any driver has committed is a different (and cheaper) event than backing out after a driver is already en route, but both need a path back to a clean state - the transition table has to name both instead of assuming cancellation only ever happens from the start.
  • What's missing for a real system: surge here is a static multiplier read at assignment time; a real system computes it from live supply/demand in a geofenced area and needs that computation to be fast enough to run on every ride request, and cancellation after a driver is already close to pickup typically carries its own small fee - a policy decision layered on top of this lifecycle, not a change to the lifecycle itself.
0%0 of 122 pages studied