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:

requestDriver() [match found]transitionTo(CANCELLED) [rider backs out before any match]transitionTo(IN_PROGRESS)transitionTo(CANCELLED) [rider backs out after a driver committed]transitionTo(COMPLETED)REQUESTEDDRIVER_ASSIGNEDIN_PROGRESSCOMPLETEDCANCELLED
Click a state to see its legal transitions.

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.

Common follow-ups

  • Why is the fare locked in at driver assignment instead of computed fresh when the ride completes? 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 own trip. Locking it in at requestDriver time is what makes the price the rider agreed to the price they actually pay.
  • How would you charge a cancellation fee only when the rider cancels after a driver is already close to pickup? This is a policy decision layered on the existing transition, not a new state - transitionTo(CANCELLED) already fires from both REQUESTED and DRIVER_ASSIGNED; the fee logic would live in the caller that invokes cancellation, checking how long the ride has been in DRIVER_ASSIGNED (or the driver's current distance from pickup) before deciding whether to charge, rather than becoming a third cancellation state on Ride itself.
  • Two riders request a ride from the same pickup area within seconds, and both get matched to the same nearest driver - what stops that? As written, nothing does: match and markBusy are separate steps, so both requestDriver calls could see the same driver as available before either flips it. Same fix as the seat-locking race in movie-booking.mdx and the partner-matching race in food-delivery.mdx - matching and marking busy need to be one atomic operation on Driver.
  • How would you support ride-pooling (two riders sharing one driver, different drop-offs)? This changes Ride from "one rider, one driver" to something that can hold multiple rider/drop-off pairs against a single Driver, and PricingStrategy.quote would need each rider's individual distance rather than one shared distanceKm - a bigger structural change than swapping a strategy implementation, since it touches what Ride itself represents, not just how one of its two strategies computes an answer.

Check yourself

Question 1 of 3

Why are driver matching and fare pricing two separate strategy interfaces instead of one combined `MatchAndPriceStrategy`?