Low Level Design (LLD) Interview Questions
Design Uber
Tier: EssentialDifficulty: HardAsked of: Mid, SeniorAsked at: Uber, Ola, Lyft
The whole problem is one line of code. Two riders request a car at the same moment, the nearest available driver is the same person for both, and only one of them can have him. Everything else in the design exists to make that line possible to write.
This is the low level design round, so the answer is classes, responsibilities and code inside one system. The system design version of this question asks about the client architecture, the map, the location stream and the API surface, and that answer is design the Uber app. Read that one for the architecture. Read this one for the objects.
What this really tests
Whether you can name three axes that genuinely vary, matching, pricing and payment, and put each behind a seam without inventing seams that do not vary. And whether you can spot that checking a driver is free and then marking them busy is two operations, not one.
What to clarify first
- Does the system push or does it broadcast. Does it assign one driver and wait, or shout at everyone nearby and let them race to accept. Modern ride hailing pushes to one driver at a time with a timeout, and that is the version worth designing.
- Is the geospatial index in scope. Ask this or you will spend twenty minutes on quadtrees. The answer you want is that finding nearby drivers is a method call, and how it is indexed is a sentence.
- Is surge in scope, and who computes the multiplier. Surge pricing itself is easy. Computing the multiplier is a streaming aggregate and is not this round.
- Are pooled rides in scope. Say no if you can. Pooling changes the trip model from one rider to many and doubles the design.
- Who can cancel and when, and is there a fee.
- Do ratings feed matching. If yes, matching gets a second policy, which is a good reason for the seam to exist.
- Is the driver location a live stream or discrete pings. Pings, and the freshness of the last one turns out to matter.
Out of scope, say so plainly. Payment card handling, the map and routing, driver onboarding, fraud, and the surge pipeline itself.
The classes
The model.
- Location is a latitude and a longitude and it knows how to measure the distance to another Location. Putting that method on the value object means no service in the system ever writes trigonometry.
- RideType is an enum, and it carries its own rates. Base fare, per kilometre, per minute. That is the reason nothing else needs a switch over ride types, and adding a new product is one line.
- Vehicle is a plate, a model and a RideType.
- Rider is an identity.
- Driver is the most important small class here. It owns a vehicle, a last known location, the instant of the last ping, and its own availability. It is the only class allowed to change that availability, and it exposes taking a job as a single call that either wins or loses. The whole race is settled inside this class.
- RideRequest carries a pickup, a drop off, a ride type and the estimates. It exists so that pricing and matching take one argument rather than six that can be passed in the wrong order.
- Trip is one journey. It holds the request, the driver once there is one, the status, and the fare that was quoted at request time. The quote is stored rather than recomputed, which is what stops a surge that started mid ride from changing a price the rider already agreed to. It also remembers which drivers let an offer lapse, so a rematch never asks the same person twice.
- TripStatus is the lifecycle, requested to offered to assigned to in progress to completed, with cancelled reachable from the first three. Offered is the state most write ups skip. A driver has been claimed and asked, and has not yet said yes. Written down once, so a completed trip cannot be cancelled and an unstarted trip cannot be completed.
The rules that vary.
- MatchingStrategy ranks candidate drivers for a request. Nearest first is the default. Best rated first and least idle time are follow ups the interviewer will ask for, and each is a new class and one line of wiring.
- PricingStrategy turns a request into a fare.
- SurgePricing wraps a PricingStrategy rather than replacing it. This is the design point worth the most in the whole answer, and it gets its own section below.
The lookups.
- DriverIndex answers one question. Which available drivers of this type are within this radius of this point right now. Behind that one method sits a linear scan in an interview and a geohash, a quadtree or an H3 grid in production. Having the seam means the production answer is a sentence rather than a detour.
The front door.
- RideService is the only class the rider app and the driver app talk to. Request a ride, accept an offer, time an offer out, start, complete, cancel. It sequences the steps, publishes events, and owns no policy at all.
- TripObserver is anything that wants to hear about a status change. The rider app, the driver app and the analytics feed are three of them.
Why ranking and claiming are separate
Most write ups give the matching strategy a method that returns the chosen driver. Do not do that.
The strategy ranks and stops. The service walks the ranked list and claims. The reason is that claiming has to be atomic, and if the strategy did the claiming then every future policy author would have to get the concurrency right again. Policy is the thing that changes often and is written by people thinking about business rules. Concurrency is the thing that must be written once and never touched.
So the strategy hands back an ordered list of names, and the service walks it, trying to claim each driver in turn. The first claim that succeeds becomes the offer.
How a ride actually gets matched
Say this walkthrough out loud at the whiteboard.
A rider asks for a sedan from a point in the city centre. The service quotes the fare first, before anything else happens, and stores that number on a new Trip in the requested state. It quotes first because the rider needs a price before they commit, and because the price has to be pinned.
The service then asks the driver index for available sedans within two kilometres, passing the current time. The index filters on three things. The driver is available, the vehicle is the right type, and the last ping is recent. That last filter matters more than it looks. A driver whose position is five minutes old is probably not where the index thinks they are, and matching on stale coordinates sends a car that has already left the area.
The service hands those candidates to the matching strategy, which sorts them by distance and hands back an ordered list. Now the interesting part. The service walks that list and tries to claim each driver, in order, for this trip. Each driver answers by checking their own availability and setting it, in one indivisible step. The first driver whose claim succeeds gets the offer, and the trip moves to offered. Every other rider racing for the same driver gets a no and simply moves on to the next name.
If nobody within two kilometres can be claimed, the search widens to five and then to ten. If nobody can be claimed at all, the request fails cleanly rather than hanging.
The driver has fifteen seconds to accept in the app. If they tap accept, the trip moves to assigned and the job is theirs. If the timer fires first, the service withdraws the offer on the trip, releases that driver, and offers to the next best driver, skipping anyone who has already let this trip lapse. The withdrawal only succeeds while the offer is still open, so a driver who tapped accept at fourteen and a half seconds keeps the job. The release is owner checked as well, which is to say it only frees the driver if that driver is still on this trip.
From there the trip moves through in progress to completed, and each move is published to whoever subscribed. The driver goes back into the pool at the end, again through an owner checked release.
Drawn out, the lifecycle has one edge that is not a straight line forward.
Patterns actually used
Strategy, twice, and both are load bearing. Matching policy and pricing policy are the two things the business genuinely changes, and both are what the follow up questions target. Without the seams, the question "now prefer drivers with a higher rating" means editing the class that also creates trips.
Decorator on pricing, and this is the distinction to make explicitly. Surge is not a second kind of pricing. It is a multiplier on whatever the pricing rule underneath produced.
Make surge a sibling implementation of PricingStrategy and it has to reimplement base plus distance plus time in order to then multiply it. Then the airport fee needs the same. Then a promotion. Then the combination of surge and airport fee, which is a fourth class. The class count grows with the number of combinations rather than the number of rules.
Make it a wrapper and it is three lines forever. It asks the thing it wraps for a number and multiplies it. It composes with every rule that exists and every rule that has not been written yet. Say that sentence in the interview, because it is the difference between having read about the decorator pattern and having understood it.
A state model on the trip. There are real illegal moves to prevent. In Java that is an enum that knows its own legal transitions, behind synchronized methods. In Kotlin it is a sealed interface behind an atomic reference, so every move is one compare and set, and there it does something the enum cannot. The driver lives inside the states that have a driver, so there is no nullable driver field to guard anywhere in the codebase. That is a genuine language difference worth pointing out rather than a translation.
Observer on trip status. One event, three audiences, none of which belong in the trip lifecycle. It is cheap and it is obviously right.
What is deliberately not here. No factory producing bike, auto and sedan objects, because those differ only in numbers and the numbers live on the enum. No singleton trip manager or driver manager, which is common in write ups and is really an in memory database wearing a pattern name. No payment strategy in the code, because it is the same shape as pricing and a second example of a pattern teaches nothing. Say each of these out loud as a thing you considered and rejected.
Java
com.androidinterview.uber.matching.DriverIndex.java
package com.androidinterview.uber.matching;
import java.time.Duration;
import java.time.Instant;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
import com.androidinterview.uber.model.Driver;
import com.androidinterview.uber.model.Location;
import com.androidinterview.uber.model.RideType;
// Where nearby drivers come from. This one scans, which is honest for an
// interview and wrong for a city. In production the same method signature
// sits over a geohash, a quadtree or an H3 grid, and saying that in one
// sentence is worth more than twenty minutes of drawing one.
public final class DriverIndex {
private static final Duration MAX_PING_AGE = Duration.ofMinutes(1);
private final Map<String, Driver> drivers = new ConcurrentHashMap<>();
public void register(Driver driver) {
drivers.put(driver.id(), driver);
}
public List<Driver> findNearby(Location pickup, double radiusKm, RideType rideType, Instant now) {
List<Driver> nearby = new ArrayList<>();
for (Driver driver : drivers.values()) {
if (!driver.isAvailable()) continue;
if (driver.vehicle().rideType() != rideType) continue;
// A driver whose last ping is old is probably not there any more.
// Matching on stale coordinates sends a car that has left.
if (Duration.between(driver.lastSeen(), now).compareTo(MAX_PING_AGE) > 0) continue;
if (driver.location().distanceKmTo(pickup) > radiusKm) continue;
nearby.add(driver);
}
return nearby;
}
}
com.androidinterview.uber.matching.MatchingStrategy.java
package com.androidinterview.uber.matching;
import java.util.List;
import com.androidinterview.uber.model.Driver;
import com.androidinterview.uber.model.RideRequest;
// Ranking only. It orders the candidates and stops there. Claiming a driver is
// the service's job, because the claim has to be atomic and a policy author
// should not have to get that right again in every new strategy.
public interface MatchingStrategy {
List<Driver> rank(RideRequest request, List<Driver> candidates);
}
com.androidinterview.uber.matching.NearestDriverStrategy.java
package com.androidinterview.uber.matching;
import java.util.Comparator;
import java.util.List;
import com.androidinterview.uber.model.Driver;
import com.androidinterview.uber.model.RideRequest;
// The policy everybody starts with. A rating aware or idle time aware version
// is a different class and one line of wiring.
public final class NearestDriverStrategy implements MatchingStrategy {
@Override
public List<Driver> rank(RideRequest request, List<Driver> candidates) {
return candidates.stream()
.sorted(Comparator.comparingDouble(
driver -> driver.location().distanceKmTo(request.pickup())))
.toList();
}
}
com.androidinterview.uber.model.Driver.java
package com.androidinterview.uber.model;
import java.time.Instant;
// The most important small class in this design, because the whole race for a
// driver is settled inside it.
public final class Driver {
private final String id;
private final String name;
private final Vehicle vehicle;
private DriverStatus status = DriverStatus.OFFLINE;
private Location location;
private Instant lastSeen;
private String currentTripId;
private double rating;
public Driver(String id, String name, Vehicle vehicle, double rating) {
this.id = id;
this.name = name;
this.vehicle = vehicle;
this.rating = rating;
}
public String id() { return id; }
public String name() { return name; }
public Vehicle vehicle() { return vehicle; }
public double rating() { return rating; }
public synchronized DriverStatus status() { return status; }
public synchronized boolean isAvailable() { return status == DriverStatus.AVAILABLE; }
public synchronized Location location() { return location; }
public synchronized Instant lastSeen() { return lastSeen; }
// Position first, availability second. The other order makes the driver
// discoverable for an instant with no location and no ping to filter on.
public synchronized void goOnline(Location at, Instant now) {
ping(at, now);
status = DriverStatus.AVAILABLE;
}
public synchronized void ping(Location at, Instant now) {
this.location = at;
this.lastSeen = now;
}
// The atomic check and set. Reading the status and then writing it as two
// separate calls leaves a window in which two trips both see AVAILABLE and
// both assign the same driver. Doing both inside one critical section
// means exactly one caller gets true and everyone else moves on to the
// next candidate.
public synchronized boolean tryAssignToTrip(String tripId) {
if (status != DriverStatus.AVAILABLE) {
return false;
}
status = DriverStatus.ON_TRIP;
currentTripId = tripId;
return true;
}
// Owner checked release, the same idea as an expiring hold. A driver who
// accepted at fourteen and a half seconds must not be freed by the offer
// timeout that fires at fifteen.
public synchronized boolean releaseIfOn(String tripId) {
if (!tripId.equals(currentTripId)) {
return false;
}
status = DriverStatus.AVAILABLE;
currentTripId = null;
return true;
}
}
com.androidinterview.uber.model.DriverStatus.java
package com.androidinterview.uber.model;
public enum DriverStatus {
OFFLINE,
AVAILABLE,
ON_TRIP
}
com.androidinterview.uber.model.Location.java
package com.androidinterview.uber.model;
// A point, and the one piece of maths this problem needs. Keeping distance on
// the value object means no service anywhere writes trigonometry.
public record Location(double latitude, double longitude) {
private static final double EARTH_RADIUS_KM = 6371.0;
public double distanceKmTo(Location other) {
double dLat = Math.toRadians(other.latitude - latitude);
double dLng = Math.toRadians(other.longitude - longitude);
double a = Math.pow(Math.sin(dLat / 2), 2)
+ Math.cos(Math.toRadians(latitude))
* Math.cos(Math.toRadians(other.latitude))
* Math.pow(Math.sin(dLng / 2), 2);
return 2 * EARTH_RADIUS_KM * Math.asin(Math.sqrt(a));
}
}
com.androidinterview.uber.model.Money.java
package com.androidinterview.uber.model;
// Minor units and a currency. A fare computed in floating point is a fare that
// disagrees with the receipt.
public record Money(String currency, long amount) {
public Money plus(Money other) {
return new Money(currency, amount + other.amount);
}
public Money scale(double factor) {
return new Money(currency, Math.round(amount * factor));
}
}
com.androidinterview.uber.model.RideRequest.java
package com.androidinterview.uber.model;
// One request, carried as a value so pricing and matching take a single
// argument rather than six that can be passed in the wrong order.
public record RideRequest(
Rider rider,
Location pickup,
Location dropOff,
RideType rideType,
double estimatedKm,
int estimatedMinutes) {
}
com.androidinterview.uber.model.RideType.java
package com.androidinterview.uber.model;
// The rates live on the enum, so nothing else in the system ever writes a
// switch over ride types. Adding a new product is one line here.
public enum RideType {
BIKE(2000, 800, 150),
AUTO(3000, 1100, 200),
SEDAN(5000, 1500, 300),
SUV(7000, 2200, 400);
private final long baseFare;
private final long perKm;
private final long perMinute;
RideType(long baseFare, long perKm, long perMinute) {
this.baseFare = baseFare;
this.perKm = perKm;
this.perMinute = perMinute;
}
public long baseFare() { return baseFare; }
public long perKm() { return perKm; }
public long perMinute() { return perMinute; }
}
com.androidinterview.uber.model.Rider.java
package com.androidinterview.uber.model;
public record Rider(String id, String name) {
}
com.androidinterview.uber.model.Trip.java
package com.androidinterview.uber.model;
import java.util.HashSet;
import java.util.Set;
// The fare is quoted once, at request time, and stored. Recomputing it at the
// end would let a surge that started mid ride change a price the rider already
// agreed to.
//
// Two kinds of transition live here. The offer protocol, offer, accept and
// withdraw, answers yes or no, because it races a timer by design and losing
// that race is an expected outcome. The rest throw, because a caller who
// starts a trip nobody accepted has a bug, not a race.
public final class Trip {
private final String id;
private final RideRequest request;
private final Money quotedFare;
private TripStatus status = TripStatus.REQUESTED;
private Driver driver;
// Drivers who let an offer lapse. A rematch skips them, otherwise the
// nearest driver who ignored the offer is simply asked again.
private final Set<String> declined = new HashSet<>();
public Trip(String id, RideRequest request, Money quotedFare) {
this.id = id;
this.request = request;
this.quotedFare = quotedFare;
}
public String id() { return id; }
public RideRequest request() { return request; }
public Money quotedFare() { return quotedFare; }
public synchronized TripStatus status() { return status; }
public synchronized Driver driver() { return driver; }
public synchronized boolean hasDeclined(String driverId) { return declined.contains(driverId); }
// Push the offer to a driver the service has already claimed. False means
// the trip is no longer waiting, which is to say the rider cancelled while
// the claim was happening, and the caller hands the driver straight back.
public synchronized boolean offerTo(Driver candidate) {
if (status != TripStatus.REQUESTED) {
return false;
}
status = TripStatus.OFFERED;
driver = candidate;
return true;
}
// Only the driver the offer went to can accept it, and only while it is
// still open. A retry from a flaky client and a tap that lands after the
// timeout both get false and change nothing.
public synchronized boolean accept(String driverId) {
if (status != TripStatus.OFFERED || !driver.id().equals(driverId)) {
return false;
}
status = TripStatus.ASSIGNED;
return true;
}
// The timeout side of the same race. It succeeds only while the offer to
// this driver is still open, so an accept that landed first wins, and it
// hands back the driver it withdrew from so the caller can release them.
public synchronized Driver withdrawOffer(String driverId) {
if (status != TripStatus.OFFERED || !driver.id().equals(driverId)) {
return null;
}
Driver withdrawn = driver;
declined.add(withdrawn.id());
driver = null;
status = TripStatus.REQUESTED;
return withdrawn;
}
public synchronized TripStatus start() {
return moveTo(TripStatus.IN_PROGRESS);
}
public synchronized TripStatus complete() {
return moveTo(TripStatus.COMPLETED);
}
public synchronized TripStatus cancel() {
return moveTo(TripStatus.CANCELLED);
}
// Returns the status it left, read under the same lock as the write, so
// the event the service publishes says where the trip really came from.
private TripStatus moveTo(TripStatus next) {
if (!status.canMoveTo(next)) {
throw new IllegalStateException("cannot move a trip from " + status + " to " + next);
}
TripStatus from = status;
status = next;
return from;
}
}
com.androidinterview.uber.model.TripStatus.java
package com.androidinterview.uber.model;
// The lifecycle, with the legal moves in one place. It stops a trip being
// completed before it started and a completed trip being cancelled.
//
// OFFERED is the state most write ups leave out. A driver has been claimed and
// asked, and has not yet said yes. The edge back to REQUESTED is what a timed
// out offer takes.
public enum TripStatus {
REQUESTED,
OFFERED,
ASSIGNED,
IN_PROGRESS,
COMPLETED,
CANCELLED;
public boolean canMoveTo(TripStatus next) {
return switch (this) {
case REQUESTED -> next == OFFERED || next == CANCELLED;
case OFFERED -> next == ASSIGNED || next == REQUESTED || next == CANCELLED;
case ASSIGNED -> next == IN_PROGRESS || next == CANCELLED;
case IN_PROGRESS -> next == COMPLETED;
case COMPLETED, CANCELLED -> false;
};
}
}
com.androidinterview.uber.model.Vehicle.java
package com.androidinterview.uber.model;
public record Vehicle(String plate, String model, RideType rideType) {
}
com.androidinterview.uber.pricing.PricingStrategy.java
package com.androidinterview.uber.pricing;
import com.androidinterview.uber.model.Money;
import com.androidinterview.uber.model.RideRequest;
public interface PricingStrategy {
Money quote(RideRequest request);
}
com.androidinterview.uber.pricing.StandardPricing.java
package com.androidinterview.uber.pricing;
import com.androidinterview.uber.model.Money;
import com.androidinterview.uber.model.RideRequest;
import com.androidinterview.uber.model.RideType;
// Base plus distance plus time, at the rates carried by the ride type.
public final class StandardPricing implements PricingStrategy {
private final String currency;
public StandardPricing(String currency) {
this.currency = currency;
}
@Override
public Money quote(RideRequest request) {
RideType type = request.rideType();
long amount = type.baseFare()
+ Math.round(type.perKm() * request.estimatedKm())
+ type.perMinute() * request.estimatedMinutes();
return new Money(currency, amount);
}
}
com.androidinterview.uber.pricing.SurgePricing.java
package com.androidinterview.uber.pricing;
import java.util.function.ToDoubleFunction;
import com.androidinterview.uber.model.Location;
import com.androidinterview.uber.model.Money;
import com.androidinterview.uber.model.RideRequest;
// Surge is a decorator over pricing, not another pricing strategy. That
// distinction is the best small idea in this problem.
//
// As a sibling it would have to reimplement base plus distance plus time, and
// then again for every future rule, airport fees, tolls, a promotion. As a
// wrapper it multiplies whatever came out of the rule underneath, so it
// composes with all of them and stays four lines forever.
//
// Where the multiplier comes from is a windowed count of requests against
// available cars in a geofence. That is a background pipeline, and for this
// round it is a function that answers a question.
public final class SurgePricing implements PricingStrategy {
private final PricingStrategy inner;
private final ToDoubleFunction<Location> multiplierAt;
public SurgePricing(PricingStrategy inner, ToDoubleFunction<Location> multiplierAt) {
this.inner = inner;
this.multiplierAt = multiplierAt;
}
@Override
public Money quote(RideRequest request) {
return inner.quote(request).scale(multiplierAt.applyAsDouble(request.pickup()));
}
}
com.androidinterview.uber.service.RideService.java
package com.androidinterview.uber.service;
import java.time.Clock;
import java.time.Duration;
import java.util.ArrayList;
import java.util.List;
import java.util.Optional;
import java.util.UUID;
import java.util.concurrent.CopyOnWriteArrayList;
import com.androidinterview.uber.matching.DriverIndex;
import com.androidinterview.uber.matching.MatchingStrategy;
import com.androidinterview.uber.model.Driver;
import com.androidinterview.uber.model.Money;
import com.androidinterview.uber.model.RideRequest;
import com.androidinterview.uber.model.Trip;
import com.androidinterview.uber.model.TripStatus;
import com.androidinterview.uber.pricing.PricingStrategy;
// The one class the apps talk to. It sequences the steps and owns nothing that
// varies. Matching policy, pricing and the driver index were all handed to it.
//
// The offer protocol is push and accept. One driver is claimed and asked, and
// a scheduler calls offerTimedOut after OFFER_WINDOW if the driver has not
// answered. The service never sleeps and never owns a timer, so a test can
// drive the whole protocol with plain method calls.
public final class RideService {
public static final Duration OFFER_WINDOW = Duration.ofSeconds(15);
private static final double[] SEARCH_RADII_KM = {2.0, 5.0, 10.0};
private final DriverIndex index;
private final MatchingStrategy matching;
private final PricingStrategy pricing;
private final Clock clock;
private final List<TripObserver> observers = new CopyOnWriteArrayList<>();
public RideService(DriverIndex index, MatchingStrategy matching,
PricingStrategy pricing, Clock clock) {
this.index = index;
this.matching = matching;
this.pricing = pricing;
this.clock = clock;
}
public void subscribe(TripObserver observer) {
observers.add(observer);
}
// Quote once, then offer. The quote is stored on the trip, so a surge that
// starts while the rider is still deciding cannot change a price they have
// already seen.
public Optional<Trip> requestRide(RideRequest request) {
Money fare = pricing.quote(request);
Trip trip = new Trip(UUID.randomUUID().toString(), request, fare);
return offer(trip) ? Optional.of(trip) : Optional.empty();
}
// Widen the circle rather than failing at the first empty ring. The walk
// over ranked candidates is where the race is settled. tryAssignToTrip
// either wins outright or returns false, and a loser simply takes the next
// name on the list. The first driver claimed gets the offer, and nobody
// else can be offered that driver while they decide.
//
// The claim comes first because it is the contested step. If the trip
// then refuses the offer, the rider cancelled while we were claiming, and
// the driver goes straight back so no driver is ever stuck on a trip that
// was never offered to them.
private boolean offer(Trip trip) {
for (double radiusKm : SEARCH_RADII_KM) {
List<Driver> candidates = index.findNearby(
trip.request().pickup(), radiusKm, trip.request().rideType(), clock.instant());
for (Driver driver : matching.rank(trip.request(), candidates)) {
if (trip.hasDeclined(driver.id()) || !driver.tryAssignToTrip(trip.id())) {
continue;
}
if (!trip.offerTo(driver)) {
driver.releaseIfOn(trip.id());
return false;
}
publish(trip, TripStatus.REQUESTED);
return true;
}
}
return false;
}
// The driver tapped accept. True means they now hold the trip. False means
// the offer was not theirs or was no longer open, and nothing changed.
public boolean acceptOffer(Trip trip, String driverId) {
if (!trip.accept(driverId)) {
return false;
}
publish(trip, TripStatus.OFFERED);
return true;
}
// The window closed without an accept. Withdraw the offer on the trip
// first and release the driver second. The other order opens a window in
// which an accept lands on a trip whose driver has already been given
// away. The withdrawal only succeeds while the offer to this driver is
// still open, so an accept at fourteen point nine beats a timeout at
// fifteen, and the driver keeps the job.
//
// Returns true when a fresh offer went out to somebody else. False means
// either the driver accepted first, or nobody else is available and the
// trip is back in REQUESTED for the caller to retry or cancel.
public boolean offerTimedOut(Trip trip, String driverId) {
Driver driver = trip.withdrawOffer(driverId);
if (driver == null) {
return false;
}
driver.releaseIfOn(trip.id());
publish(trip, TripStatus.OFFERED);
return offer(trip);
}
public void startTrip(Trip trip) {
publish(trip, trip.start());
}
public void completeTrip(Trip trip) {
TripStatus from = trip.complete();
trip.driver().releaseIfOn(trip.id());
publish(trip, from);
}
// Either side can cancel, and the driver goes back in the pool either way.
// A second cancel throws on the status guard before it reaches the
// release, which is what stops two cancellations freeing the driver twice.
// Whether a fee is charged is a policy question, and policy does not belong
// in the class that moves cars around.
public void cancelTrip(Trip trip) {
Driver driver = trip.driver();
TripStatus from = trip.cancel();
if (driver != null) {
driver.releaseIfOn(trip.id());
}
publish(trip, from);
}
private void publish(Trip trip, TripStatus from) {
List<TripObserver> snapshot = new ArrayList<>(observers);
for (TripObserver observer : snapshot) {
observer.onStatusChanged(trip, from);
}
}
}
com.androidinterview.uber.service.TripObserver.java
package com.androidinterview.uber.service;
import com.androidinterview.uber.model.Trip;
import com.androidinterview.uber.model.TripStatus;
// One event, several audiences. The rider app, the driver app and the
// analytics feed all want the same status change and none of them belong in
// the trip lifecycle, so the service publishes and forgets.
@FunctionalInterface
public interface TripObserver {
void onStatusChanged(Trip trip, TripStatus from);
}
Kotlin
com.androidinterview.uber.matching.Matching.kt
package com.androidinterview.uber.matching
import com.androidinterview.uber.model.Driver
import com.androidinterview.uber.model.Location
import com.androidinterview.uber.model.RideRequest
import com.androidinterview.uber.model.RideType
import java.time.Duration
import java.time.Instant
import java.util.concurrent.ConcurrentHashMap
// Ranking only. It orders the candidates and stops there. Claiming a driver is
// the service's job, because the claim has to be atomic and a policy author
// should not have to get that right again in every new strategy.
//
// It is a function type, not an interface, because it has no state and no
// configuration. A rating aware policy is a lambda.
typealias MatchingStrategy = (RideRequest, List<Driver>) -> List<Driver>
val nearestFirst: MatchingStrategy = { request, candidates ->
candidates.sortedBy { it.location.distanceKmTo(request.pickup) }
}
val bestRatedFirst: MatchingStrategy = { _, candidates ->
candidates.sortedByDescending { it.rating }
}
// Where nearby drivers come from. This one scans, which is honest for an
// interview and wrong for a city. In production the same signature sits over a
// geohash, a quadtree or an H3 grid.
class DriverIndex {
private val drivers = ConcurrentHashMap<String, Driver>()
fun register(driver: Driver) {
drivers[driver.id] = driver
}
fun findNearby(pickup: Location, radiusKm: Double, rideType: RideType, now: Instant): List<Driver> =
drivers.values.filter { driver ->
driver.isAvailable &&
driver.vehicle.rideType == rideType &&
// A driver whose last ping is old is probably not there any
// more. Matching on stale coordinates sends a car that has left.
Duration.between(driver.lastSeen, now) <= MAX_PING_AGE &&
driver.location.distanceKmTo(pickup) <= radiusKm
}
private companion object {
val MAX_PING_AGE: Duration = Duration.ofMinutes(1)
}
}
com.androidinterview.uber.model.Domain.kt
package com.androidinterview.uber.model
import kotlin.math.PI
import kotlin.math.asin
import kotlin.math.cos
import kotlin.math.roundToLong
import kotlin.math.sin
import kotlin.math.sqrt
// Minor units and a currency. A fare computed in floating point is a fare that
// disagrees with the receipt.
data class Money(val currency: String, val amount: Long) {
operator fun times(factor: Double) = copy(amount = (amount * factor).roundToLong())
}
// A point, and the one piece of maths this problem needs. Keeping distance on
// the value object means no service anywhere writes trigonometry.
data class Location(val latitude: Double, val longitude: Double) {
fun distanceKmTo(other: Location): Double {
val dLat = (other.latitude - latitude).toRadians()
val dLng = (other.longitude - longitude).toRadians()
val a = sin(dLat / 2) * sin(dLat / 2) +
cos(latitude.toRadians()) * cos(other.latitude.toRadians()) *
sin(dLng / 2) * sin(dLng / 2)
return 2 * EARTH_RADIUS_KM * asin(sqrt(a))
}
private companion object {
const val EARTH_RADIUS_KM = 6371.0
fun Double.toRadians() = this * PI / 180
}
}
// The rates ride on the enum, so nothing else ever writes a when over ride
// types. A new product is one line here.
enum class RideType(val baseFare: Long, val perKm: Long, val perMinute: Long) {
BIKE(2000, 800, 150),
AUTO(3000, 1100, 200),
SEDAN(5000, 1500, 300),
SUV(7000, 2200, 400),
}
data class Vehicle(val plate: String, val model: String, val rideType: RideType)
data class Rider(val id: String, val name: String)
data class RideRequest(
val rider: Rider,
val pickup: Location,
val dropOff: Location,
val rideType: RideType,
val estimatedKm: Double,
val estimatedMinutes: Int,
)
com.androidinterview.uber.model.Driver.kt
package com.androidinterview.uber.model
import java.time.Instant
import java.util.concurrent.atomic.AtomicReference
// What a driver is doing, as a sealed interface. The trip id exists only in the
// state that has one, so there is no field that is meaningfully null half the
// time.
sealed interface DriverAssignment {
data object Offline : DriverAssignment
data object Available : DriverAssignment
data class OnTrip(val tripId: String) : DriverAssignment
}
// The most important small class in this design, because the whole race for a
// driver is settled inside it.
class Driver(
val id: String,
val name: String,
val vehicle: Vehicle,
val rating: Double,
initialLocation: Location,
initialPing: Instant,
) {
// One immutable value behind an atomic reference, so the check and the set
// really are one operation rather than two under a lock.
private val assignment = AtomicReference<DriverAssignment>(DriverAssignment.Offline)
@Volatile
var location: Location = initialLocation
private set
@Volatile
var lastSeen: Instant = initialPing
private set
val isAvailable: Boolean get() = assignment.get() == DriverAssignment.Available
fun goOnline(at: Location, now: Instant) {
ping(at, now)
assignment.set(DriverAssignment.Available)
}
fun ping(at: Location, now: Instant) {
location = at
lastSeen = now
}
// compareAndSet is the check and the set in one instruction. Exactly one
// caller can move this driver out of Available. Every other caller gets
// false and takes the next name on the list.
fun tryAssignToTrip(tripId: String): Boolean =
assignment.compareAndSet(DriverAssignment.Available, DriverAssignment.OnTrip(tripId))
// Owner checked release. Read the current value, check the trip id it
// carries, and swap against exactly the value that was read. The swap has
// to be against the read value and not a fresh OnTrip(tripId), because
// compareAndSet compares references, not data class equality. A driver who
// accepted a moment before the offer timed out keeps the trip.
fun releaseIfOn(tripId: String): Boolean {
val before = assignment.get()
return before is DriverAssignment.OnTrip &&
before.tripId == tripId &&
assignment.compareAndSet(before, DriverAssignment.Available)
}
}
com.androidinterview.uber.model.Trip.kt
package com.androidinterview.uber.model
import java.util.concurrent.ConcurrentHashMap
import java.util.concurrent.atomic.AtomicReference
// The lifecycle as a sealed interface rather than an enum, and this is where
// Kotlin genuinely beats the Java version. A driver exists only in the states
// that have one, so there is no nullable driver field to guard.
//
// Offered is the state most write ups leave out. A driver has been claimed and
// asked, and has not yet said yes. The move back to Requested is what a timed
// out offer takes.
sealed interface TripState {
data object Requested : TripState
data class Offered(val driver: Driver) : TripState
data class Assigned(val driver: Driver) : TripState
data class InProgress(val driver: Driver) : TripState
data class Completed(val driver: Driver) : TripState
data class Cancelled(val reason: String) : TripState
}
// The driver, for the states that have one. Written once as an extension so
// neither the trip nor the service repeats the when.
val TripState.driver: Driver?
get() = when (this) {
is TripState.Offered -> driver
is TripState.Assigned -> driver
is TripState.InProgress -> driver
is TripState.Completed -> driver
TripState.Requested, is TripState.Cancelled -> null
}
// The fare is quoted once, at request time, and stored. Recomputing it at the
// end would let a surge that started mid ride change a price the rider already
// agreed to.
//
// The state sits behind an atomic reference and every move is a compareAndSet
// against the exact value that was read, so two callers can never both pass a
// check and both write. The offer protocol answers yes or no, because it races
// a timer by design. The rest throw, because a caller who starts a trip nobody
// accepted has a bug, not a race.
class Trip(val id: String, val request: RideRequest, val quotedFare: Money) {
private val current = AtomicReference<TripState>(TripState.Requested)
// Drivers who let an offer lapse. A rematch skips them, otherwise the
// nearest driver who ignored the offer is simply asked again.
private val declined = ConcurrentHashMap.newKeySet<String>()
val state: TripState get() = current.get()
fun hasDeclined(driverId: String): Boolean = driverId in declined
val driver: Driver? get() = state.driver
// Push the offer to a driver the service has already claimed. False means
// the trip is no longer waiting, which is to say the rider cancelled while
// the claim was happening, and the caller hands the driver straight back.
fun offerTo(driver: Driver): Boolean =
current.compareAndSet(TripState.Requested, TripState.Offered(driver))
// Only the driver the offer went to can accept it, and only while it is
// still open. Returns the offer it closed, or null when nothing changed.
fun accept(driverId: String): TripState.Offered? =
swapOffer(driverId) { TripState.Assigned(it.driver) }
// The timeout side of the same race. It succeeds only while the offer to
// this driver is still open, so an accept that landed first wins.
fun withdrawOffer(driverId: String): TripState.Offered? =
swapOffer(driverId) { TripState.Requested }?.also { declined += it.driver.id }
fun start(): TripState.Assigned =
move("a trip must be assigned before it starts") { TripState.InProgress(it.driver) }
fun complete(): TripState.InProgress =
move("a trip must be running before it completes") { TripState.Completed(it.driver) }
// Two cancellations at once both read an open state. Exactly one swap
// succeeds, and the other throws instead of releasing the driver twice.
fun cancel(reason: String): TripState {
val before = current.get()
val open = before is TripState.Requested || before is TripState.Offered || before is TripState.Assigned
check(open && current.compareAndSet(before, TripState.Cancelled(reason))) {
"a trip that has started or already ended cannot be cancelled"
}
return before
}
private inline fun swapOffer(driverId: String, next: (TripState.Offered) -> TripState): TripState.Offered? {
val before = current.get() as? TripState.Offered ?: return null
if (before.driver.id != driverId) return null
return if (current.compareAndSet(before, next(before))) before else null
}
// Each move returns the state it left, so the service can publish where
// the trip really came from rather than a second, racy read.
private inline fun <reified S : TripState> move(message: String, next: (S) -> TripState): S {
val before = current.get()
check(before is S && current.compareAndSet(before, next(before))) { message }
return before
}
}
com.androidinterview.uber.pricing.Pricing.kt
package com.androidinterview.uber.pricing
import com.androidinterview.uber.model.Location
import com.androidinterview.uber.model.Money
import com.androidinterview.uber.model.RideRequest
import kotlin.math.roundToLong
fun interface PricingStrategy {
fun quote(request: RideRequest): Money
}
// Base plus distance plus time, at the rates carried by the ride type.
fun standardPricing(currency: String) = PricingStrategy { request ->
val type = request.rideType
val amount = type.baseFare +
(type.perKm * request.estimatedKm).roundToLong() +
type.perMinute * request.estimatedMinutes
Money(currency, amount)
}
// Surge decorates pricing, it is not a sibling of it, and that distinction is
// the best small idea in this problem.
//
// As a sibling it would have to reimplement base plus distance plus time, and
// again for every future rule, airport fees, tolls, a promotion. As a wrapper it
// multiplies whatever came out of the rule underneath, so it composes with all
// of them and stays three lines forever.
fun PricingStrategy.withSurge(multiplierAt: (Location) -> Double) = PricingStrategy { request ->
quote(request) * multiplierAt(request.pickup)
}
com.androidinterview.uber.service.RideService.kt
package com.androidinterview.uber.service
import com.androidinterview.uber.matching.DriverIndex
import com.androidinterview.uber.matching.MatchingStrategy
import com.androidinterview.uber.model.RideRequest
import com.androidinterview.uber.model.Trip
import com.androidinterview.uber.model.TripState
import com.androidinterview.uber.model.driver
import com.androidinterview.uber.pricing.PricingStrategy
import java.time.Clock
import java.time.Duration
import java.util.UUID
import java.util.concurrent.CopyOnWriteArrayList
// One event, several audiences. The observer gets the trip and the state it
// left, so a subscriber that cares about a transition rather than a state can
// be written.
typealias TripObserver = (trip: Trip, from: TripState) -> Unit
// The one class the apps talk to. It sequences the steps and owns nothing that
// varies. Matching policy, pricing and the driver index were all handed to it.
//
// The offer protocol is push and accept. One driver is claimed and asked, and a
// scheduler calls offerTimedOut after OFFER_WINDOW if the driver has not
// answered. The service never sleeps and never owns a timer, so a test can
// drive the whole protocol with plain calls.
class RideService(
private val index: DriverIndex,
private val matching: MatchingStrategy,
private val pricing: PricingStrategy,
private val clock: Clock,
) {
private val observers = CopyOnWriteArrayList<TripObserver>()
fun subscribe(observer: TripObserver) {
observers += observer
}
// Quote once, then offer. The quote is stored on the trip, so a surge that
// starts while the rider is still deciding cannot change a price they have
// already seen.
fun requestRide(request: RideRequest): Trip? {
val trip = Trip(UUID.randomUUID().toString(), request, pricing.quote(request))
return if (offer(trip)) trip else null
}
// Widen the circle rather than failing at the first empty ring. The claim is
// the predicate of firstOrNull, so the first driver whose compareAndSet wins
// is the driver we offer to, and a loser costs nothing but a step down the
// list. The claim comes first because it is the contested step. If the trip
// then refuses the offer, the rider cancelled while we were claiming, and
// the driver goes straight back.
private fun offer(trip: Trip): Boolean {
for (radiusKm in SEARCH_RADII_KM) {
val candidates =
index.findNearby(trip.request.pickup, radiusKm, trip.request.rideType, clock.instant())
val claimed = matching(trip.request, candidates)
.firstOrNull { !trip.hasDeclined(it.id) && it.tryAssignToTrip(trip.id) }
?: continue
if (!trip.offerTo(claimed)) {
claimed.releaseIfOn(trip.id)
return false
}
publish(trip, TripState.Requested)
return true
}
return false
}
// The driver tapped accept. True means they now hold the trip. False means
// the offer was not theirs or was no longer open, and nothing changed.
fun acceptOffer(trip: Trip, driverId: String): Boolean {
val from = trip.accept(driverId) ?: return false
publish(trip, from)
return true
}
// The window closed without an accept. Withdraw the offer on the trip first
// and release the driver second. The other order opens a window in which an
// accept lands on a trip whose driver has already been given away. The
// withdrawal only succeeds while the offer to this driver is still open, so
// an accept at fourteen point nine beats a timeout at fifteen.
//
// True means a fresh offer went out to somebody else. False means either
// the driver accepted first, or nobody else is available and the trip is
// back in Requested for the caller to retry or cancel.
fun offerTimedOut(trip: Trip, driverId: String): Boolean {
val from = trip.withdrawOffer(driverId) ?: return false
from.driver.releaseIfOn(trip.id)
publish(trip, from)
return offer(trip)
}
fun startTrip(trip: Trip) = publish(trip, trip.start())
fun completeTrip(trip: Trip) {
val from = trip.complete()
from.driver.releaseIfOn(trip.id)
publish(trip, from)
}
// Either side can cancel and the driver goes back in the pool either way.
// A second cancel throws on the swap before it reaches the release, which
// is what stops two cancellations freeing the driver twice. Whether a fee
// is charged is policy, and policy does not belong in the class that moves
// cars around.
fun cancelTrip(trip: Trip, reason: String) {
val from = trip.cancel(reason)
from.driver?.releaseIfOn(trip.id)
publish(trip, from)
}
private fun publish(trip: Trip, from: TripState) = observers.forEach { it(trip, from) }
companion object {
val OFFER_WINDOW: Duration = Duration.ofSeconds(15)
private val SEARCH_RADII_KM = listOf(2.0, 5.0, 10.0)
}
}
Concurrency and edge cases
The core race, in words first. Two riders request at the same instant and the same driver is nearest to both. Thread one reads the driver as available. Before it writes anything, thread two also reads the driver as available. Both then write busy and both create a trip. One rider is standing on a corner watching a car drive to somebody else. This is time of check to time of use, and it is the bug the whole question exists to find.
The fix is that checking and setting are one operation. The driver exposes a single call that means "take this trip if you are free", and it does the read and the write inside one critical section. Exactly one caller gets true.
In Java that is a synchronized method on the driver. In Kotlin it is nicer, because the assignment is one immutable value behind an atomic reference and the claim is a single compareAndSet. The owner checked release reads the current value, checks the trip id it carries, and swaps against exactly the value it read, so the check and the release are still one step.
Be honest about what that solves. It solves the race between two threads in one process. A real dispatch system runs many servers, and a lock or an atomic reference inside one of them means nothing to the other nine. The same guarantee has to come from somewhere shared. The usual shape is a conditional update in the database, setting the driver to on trip only where the driver is still available, and then checking how many rows changed. A distributed lock is the alternative and it needs a fencing token to be safe. Say this rather than pretending a synchronized block scales.
The offer timeout that steals an accepted trip. The system pushes to one driver and waits fifteen seconds. The driver taps accept at fourteen point nine. The timeout fires at fifteen. If the timeout just frees the driver, the trip is torn away from a driver who accepted it. The fix is two guards in a fixed order. The timeout first withdraws the offer on the trip, and that withdrawal is a compare and set that only succeeds while the offer to that driver is still open, so an accept that landed first has already moved the trip and the withdrawal fails. Only after the withdrawal succeeds does it release the driver, and that release is owner checked too, the same idea as an expiring room hold. Doing it the other way round, driver first and trip second, opens a window in which an accept lands on a trip whose driver has already been given away.
A duplicate accept from a flaky client. The driver's phone retries the accept call. The second one has to be a no op rather than a second assignment, which it is, because the trip is no longer in offered and the compare fails.
Stale driver locations. Covered above, and worth saying explicitly because most answers miss it. Filter candidates by ping freshness or the index will confidently hand you a driver who is three neighbourhoods away.
Quoted fare against charged fare. Pin the quote at request time, including the surge multiplier that was in force then. Recomputing at drop off produces a different number and an angry rider. If the route changed materially, that is a repricing event with its own rules, not an accident.
Both sides cancel at once. Both cancellations try to move the same trip to cancelled. The status guard means the second one throws rather than double releasing the driver. In Java that guard is a synchronized method, in Kotlin it is the compare and set failing on a state that has already moved. Catch it at the edge and treat it as already cancelled.
The trip completes but the payment fails. The trip is still complete and the driver still has to be released and paid. A failed charge is a debt against the rider, not a reason to hold a driver hostage. Say this, because it is the kind of thing that separates a design from a demo.
Nobody is available. Widen the radius in steps, then fail cleanly with a message. Never leave the request hanging while a spinner turns.
Where the Kotlin differs, and why
Two places, and both are worth mentioning in the room.
The trip state is a sealed interface with a driver inside the states that have one, rather than an enum plus a nullable field, and it sits behind an atomic reference so every move is one compare and set against the exact value that was read. Illegal states stop being something you guard against and start being something you cannot write.
The driver's availability is an atomic reference over a sealed value rather than a synchronized field. The claim is a single compareAndSet from available to on trip. The owner checked release reads the value, checks the trip id it carries, and swaps against that same value. Two of the design's hardest requirements collapse into one primitive.
Watch