Ride Sharing
Prefer to learn one decision at a time?
Try questions, reveal the reasoning, trace the code, and finish with the simulator.
Design a ride-sharing service that finds the fastest available driver by road travel time, creates a ride only when both routes exist, and moves the ride safely from matched to completed.

1. Requirements (~5 minutes)
The prompt usually begins with only:
“Design a ride-sharing system.”
That could mean driver onboarding, live maps, pricing, payments, dispatch, safety, or a complete distributed platform. An LLD answer becomes useful only after we choose one complete workflow and make every rule observable.
Do not start with Rider, Driver, and Ride. First ask what a request contains, how a driver wins, and which state must remain unchanged when no route exists.
Use these four question groups every time
What must a user be able to do?
When does an action succeed, fail, or change state?
Which invalid actions must we reject?
What should we deliberately not build?
Ask these questions
Actions
AskWhich actions must the first version support?Show answerHide answer
Interviewer answer
A registered rider requests one ride. The system matches an available driver. The ride can then start and complete.
AskWhat locations does a ride request contain?Show answerHide answer
Interviewer answer
The interview version uses pre-created road-graph location IDs for pickup and destination. They must exist and be different.
Rules
AskHow is the driver chosen?Show answerHide answer
Interviewer answer
Consider only AVAILABLE drivers who can reach the pickup. Choose the smallest road travel time; break equal times by driver ID.
AskDo we also need a route from pickup to destination before matching?Show answerHide answer
Interviewer answer
Yes. Validate the trip route first. If pickup cannot reach destination, reject without matching or changing a driver.
AskWhat road information is available, and may weights be negative?Show answerHide answer
Interviewer answer
Intersections are locations, directed road segments are edges, and each positive integer weight is travel minutes. Some roads may be two-way.
AskWhat changes when a driver is matched and when the ride completes?Show answerHide answer
Interviewer answer
A matched driver becomes BUSY. On completion, the ride becomes COMPLETED, the driver moves to the destination, and becomes AVAILABLE.
Errors
AskWhat happens if the trip is unreachable or no available driver can reach pickup?Show answerHide answer
Interviewer answer
Return UNREACHABLE_TRIP or NO_DRIVER_AVAILABLE. Do not create Ride and do not change any Driver.
Boundaries
AskWhich map, traffic, scale, storage, and concurrency concerns are in scope?Show answerHide answer
Interviewer answer
Use one fixed in-memory graph, pre-created users, static travel minutes, and sequential calls. Exclude GPS streaming, traffic, spatial indexes, persistence, and concurrent dispatch.
Confirm the specification
Confirmed specification
10 requirements · 10 boundaries
The first version must
- 1.Riders, drivers, locations, and roads are pre-created in one in-memory process.
- 2.A request contains a registered rider ID plus different, known pickup and destination IDs.
- 3.Roads are directed edges with positive integer travel minutes; two-way roads are stored as two directed edges.
- 4.A valid trip route from pickup to destination must exist before driver matching begins.
- 5.Only AVAILABLE drivers with a route to pickup are candidates.
- 6.Choose the candidate with the smallest pickup-route minutes; break ties by driver ID.
- 7.A successful request creates one Ride containing both routes and changes its Driver to BUSY.
- 8.A ride follows MATCHED → IN_PROGRESS → COMPLETED; other transitions are rejected.
- 9.Completion moves the driver to the destination and makes the driver AVAILABLE.
- 10.Any rejected request leaves ride IDs, ride storage, and every driver unchanged.
Not building in this version10 boundaries
- Driver acceptance and cancellation
- Fare calculation and surge pricing
- Payments and refunds
- Live GPS updates and map matching
- Live traffic and road closures
- Turn restrictions and toll preferences
- Geographic or nearest-neighbor indexes
- Persistent storage and APIs
- Concurrent or multi-server dispatch
- Notifications, safety, ratings, and support
2. Entities and relationships (~3 minutes)
Extract candidate nouns in three passes: road model (location, road, graph, route), people and intent (rider, driver, ride request), then workflow and decisions (match, ride, status). Vehicle, payment, and map are also nouns, but no confirmed rule requires them in this version. The final model keeps only concepts that remember state, carry validated data, enforce a rule, or define replaceable behavior.
Final model
Types we will create
- Class
- Owns changing state or coordinates rules.
- Record
- A Java record for validated, immutable data with value equality.
- Interface
- Defines behavior that another implementation can replace.
Owns locations and directed adjacency lists.
Owns availability and current location.
Owns accepted routes and lifecycle.
Coordinates validation, matching, IDs, and state changes.
Identifies one stable node in the road graph.
Describes one directed road and its positive travel time.
Carries an immutable path and its total travel time.
Carries stable rider identity.
Carries one rider's pickup and destination intent.
Returns the chosen driver with the pickup route.
Returns a named request outcome and accepted ride.
Keeps path calculation replaceable.
Keeps driver selection replaceable.
Enums
Enums name a small, fixed set of valid choices or outcomes.
Names whether a driver can receive a ride.
Names the lifecycle of an accepted ride.
Explains an accepted or rejected ride request.
Explains the result of starting or completing a ride.
Fields
Fields are simple values stored inside another type; they do not need independent behavior in this version.
Identifies a node in the road graph.
Stores the cost of a road or completed route.
Identifies where the rider must be collected.
Identifies where the accepted ride must finish.
Allocates deterministic IDs only to accepted rides.
See one request move through the objects
Swipe to follow the flow →

Follow the orange arrows. Rider sends a request to RideService. The service first asks RoutingStrategy for a trip route, and RoutingStrategy reads RoadGraph. It then asks DriverMatchingStrategy to compare Drivers; the matcher calls the same RoutingStrategy for pickup routes.
Now follow the teal returns. A valid trip route and a driver + pickup route return to RideService. Only then does it create Ride, tell the chosen Driver to become busy, and return the matched ride. The arrows teach the mutation boundary: route search is read-only; assignment is the final coordinated state change.
3. Class design (10–15 minutes)
Bridge the entities into code with the usual chain:
requirement → responsible object → state it remembers → method that changes or reads that state
Start at RideService.requestRide because it receives the main user action. Then follow the questions it delegates: can the trip be routed, which driver wins, and which objects change after success?
One class at a time
Design the state owners
Start with the coordinator, then follow the request into the classes that protect changing state. Select a class to see only the fields and behavior needed for its responsibility.
Class 1 of 4
RideService
Coordinates validation, routing, matching, IDs, and accepted lifecycle actions.
Requirement behind this class
A request succeeds only when the trip and pickup routes exist and an available driver can be assigned.
State it remembers
Private information required to perform this class's job.
Provides the road model to both algorithms.
Validates registered rider identity.
Provides the matching candidates.
Finds accepted rides for lifecycle actions.
Calculates one route without embedding Dijkstra in the service.
Chooses a reachable available driver.
Allocates IDs only for accepted matches.
Behavior it owns
Public use cases and internal rules kept beside that state.
Proves the trip, finds a driver, then assigns and records the Ride.
Delegates the guarded MATCHED to IN_PROGRESS transition.
Completes the Ride and releases the Driver at the destination.
Expose safe current-state reads.
- Invariant it protects
- Driver and Ride mutate only after trip routing and pickup matching both succeed.
- Works with
- RoadGraph, both Strategy interfaces, Rider, Driver, Ride, and result records.
- Does not own
- Graph traversal, pickup comparison details, or direct lifecycle field changes.
- Principle demonstrated
- Dependency inversion separates orchestration from algorithms that may vary.
Supporting types
These types carry stable data or define replaceable behavior. They support the state owners without becoming another place to store live workflow state.
Carry stable graph, request, route, match, and outcome data.
Immutable values prevent accepted endpoints and paths from changing underneath matching or Ride.
Defines optional route(graph, start, target) behavior.
Dijkstra is one implementation; RoadGraph remains the data owner.
Calculates the cheapest positive-weight route with a priority queue.
It reads graph adjacency and returns Route without modifying graph or driver state.
Defines comparison of reachable available drivers.
It reuses RoutingStrategy for pickup routes and cannot assign the winning Driver.
Filters BUSY or unreachable drivers and compares pickup minutes then ID.
RideService performs mutation only after this implementation returns DriverMatch.
The explorer keeps RoadGraph storage, routing, matching, and accepted state changes separate. The next sections focus on the algorithm owned by RoutingStrategy and the graph data it reads.
Routing concept: turn roads into a graph
Imagine intersections A, B, and C. Each intersection becomes a node. A road from A to B becomes an edge. The number of travel minutes becomes the edge's weight—the cost of using that road.
A straight line on a screen is not a route. A river, one-way street, or missing bridge can make a visually close driver slow or unreachable. This is why matching must compare road-route minutes, not coordinate distance.
RoadGraph stores outgoing edges as an adjacency list: for each node, keep the roads that may be taken from it. A two-way street is two directed edges. This representation uses O(V + E) space for V locations and E road segments.
Routing and matching are not the same algorithm
Routing receives two locations and returns a path. Matching receives many drivers, filters business state such as AVAILABLE, asks routing about each candidate, and selects one result.
If we mix them, Dijkstra starts checking driver status and Driver begins knowing graph edges. Both classes become harder to test. The design therefore keeps this call direction:
DriverMatchingStrategy
└─ for each AVAILABLE Driver
└─ RoutingStrategy.route(driver.location, pickup)
The simple matcher runs Dijkstra once per available driver. With D drivers, its cost is O(D × (V + E) log V). That is honest and clear for an interview-sized graph. A production system would first use a geographic index or run a reverse/multi-source search to reduce candidates.
Why breadth-first search is not enough
Breadth-first search, or BFS, minimizes the number of edges. It is correct when every road has equal cost. Here one road may take 1 minute and another 8 minutes.
From C to D, BFS sees the direct one-edge road C → D and returns 8 minutes. But C → B → D has two edges and costs only 1 + 5 = 6 minutes. Fewer roads does not mean less travel time.
Dijkstra's algorithm in plain English
Dijkstra works when every edge weight is non-negative. It grows the cheapest known region outward from the start.
- A distance estimate is the best travel time found so far for a node.
- The frontier contains discovered nodes whose outgoing roads may still improve other estimates.
- A priority queue returns the frontier node with the smallest estimate first.
- A settled node has been removed with its cheapest final distance.
- Relaxing an edge means asking whether travelling through the current node gives its neighbour a cheaper estimate.
- A predecessor records which previous node created an improved estimate, allowing the final path to be rebuilt backward.
Trace A to pickup D on our graph:
- Start with
A = 0; every other distance is infinity, written∞. - Settle
A. Roads discoverC = 2andB = 4. - Settle
C = 2.C → BimprovesBfrom 4 to 3. It also discoversD = 10andE = 12. - Settle
B = 3.B → DimprovesDfrom 10 to 8. - Settle
D = 8. Stop and follow predecessors backward:D ← B ← C ← A. - Reverse that chain to return
A → C → B → D, 8 minutes.
The priority queue may hold an older entry such as B = 4 after B = 3 is found. The implementation skips stale entries rather than trying to edit the queue in place.
Dijkstra takes O((V + E) log V) time with adjacency lists and a binary priority queue, and O(V + E) space including the graph, estimates, predecessors, settled set, and queue.
Dijkstra's assumptions and alternatives
Negative road weights would break the “settled means final” promise, but negative travel time has no physical meaning here. Static positive minutes therefore fit Dijkstra well.
BFS is simpler for equal weights. A* can explore less of a large map by adding a heuristic, an estimate of remaining cost such as straight-line time. That heuristic must never overestimate if we want the normal shortest-path guarantee. Coordinates and a safe heuristic are not part of this first model, so forcing A* would add fields without a current need.
- 1Trip routeRideService first finds D → E → F in 5 minutes, cheaper than direct D → F in 6.
- 2MayaMatching routes B → D in 5 minutes.
- 3LeoMatching routes E → D in 2 minutes.
- 4NoorMatching routes A → C → B → D in 8 minutes.
- 5ChooseLeo has the smallest reachable pickup time, so Driver D2 wins and becomes BUSY.
- 6CompleteRide reaches F; Leo moves to F and becomes AVAILABLE again.
Decide whether patterns help
Use: Strategy for routing
Path calculation can change independently of ride state. RoutingStrategy.route is the boundary, and DijkstraRoutingStrategy is the current implementation. A future A* or external map adapter can return the same Route without changing matching or Ride.
Use: Strategy for driver matching
Driver choice is a separate varying business rule. NearestDriverMatchingStrategy currently filters AVAILABLE drivers and compares pickup minutes, then ID. A future rating-aware or vehicle-aware policy can change that comparison while reusing routing.
Do not force: State pattern for Ride
Ride has three states and two short guarded transitions. An enum plus start and complete keeps the rule visible. Separate state classes would add indirection before states have different complex behavior.
Do not force: Observer for ride events
Notifications and analytics are out of scope, so there are no listeners. Introduce domain events only when successful matching or completion has several confirmed reactions.
Do not force: Singleton RideService
A global object would share mutable test state and would not coordinate multiple servers. Normal construction makes the graph, users, and strategies explicit.
Prove matching and routing interactively
Request the default ride. The comparison should choose Leo at node E even though Maya at B appears visually closer to pickup D. Then advance Dijkstra one step at a time. Load the unreachable pickup to see a valid G → F trip rejected because no driver can enter one-way node G.
Match, then route
Ride dispatch and Dijkstra lab
Known distances
This Dijkstra step
Ready to choose the cheapest frontier node
- • Start at E. Its known time is 0.
New ride request
Driver comparison
What dispatch decided
- Ready: request Museum (D) → Airport (F). Road time, not visual distance, should choose Leo.
Connect the classes and review the final blueprint
RideService composes RoadGraph, both Strategy interfaces, Riders, Drivers, and accepted Rides. Routing reads graph values; Matching reads Drivers and calls Routing; only RideService coordinates accepted mutation. This one-way call direction prevents graph algorithms from acquiring business-state responsibilities.
- Immutability: graph values, requests, routes, matches, and results remain stable.
- Encapsulation: Driver and Ride expose guarded transitions rather than setters.
- Algorithmic separation: RoadGraph stores roads, Routing finds paths, and Matching compares candidates.
- Dependency inversion: RideService and Matching depend on Strategy interfaces instead of Dijkstra details.
- Validation before mutation: trip and pickup routes must exist before the driver, ID sequence, or ride index changes.

How to read it: Immutable graph values feed RoadGraph adjacency lists.
Design principle: Encapsulation keeps map structure read-only to algorithms.
4. Java implementation (~10 minutes)
What the code does
This Java 17 program builds a directed weighted road graph, finds shortest routes with Dijkstra, compares available drivers by pickup time, creates matched rides, and enforces start and completion transitions.
Why it is structured this way
RoadGraph owns map data, RoutingStrategy owns path calculation, DriverMatchingStrategy owns driver choice, Driver owns availability, Ride owns lifecycle, and RideService coordinates the successful mutation boundary.
Follow one request through the code
- 1RideService validates the rider and endpoint IDs.
- 2DijkstraRoutingStrategy proves that pickup can reach destination.
- 3NearestDriverMatchingStrategy routes every AVAILABLE driver to pickup.
- 4The matcher chooses lower pickup minutes, then lower driver ID.
- 5RideService creates Ride only after both routes exist and asks Driver to become BUSY.
- 6Completion changes Ride first, then moves Driver to destination and makes it AVAILABLE.
Code concepts to understand
Adjacency lists and directed roadsRoadGraph stores only outgoing roads, so one-way behavior is natural.
addDirectedRoad creates one Road. addTwoWayRoad deliberately creates two. roadsFrom returns an immutable copy, which lets Dijkstra read graph state without editing it.
Dijkstra's distance, predecessor, and settled mapsThree small structures answer cost, path, and finality.
distance stores the cheapest known minutes. previous stores the parent that produced an improvement. settled prevents a node's outgoing roads from being processed twice. The priority queue selects the lowest (minutes, locationId) entry deterministically.
Relaxation and stale queue entriesA newly found path replaces an estimate only when it is strictly cheaper.
The code computes candidate = current.minutes + road.minutes. When candidate is smaller, it updates distance and predecessor and adds another queue entry. If an older entry is later removed, its minutes no longer equal the current distance, so it is skipped.
Matching calls routing; routing does not matchThe two Strategy patterns isolate two different reasons to change.
NearestDriverMatchingStrategy filters Driver status, calls RoutingStrategy once per candidate, and compares Route results. Dijkstra never sees Driver, Rider, or Ride. This is the exact separation shown by the entity diagram.
Validation before mutationAn impossible route cannot consume an ID or busy a driver.
RideService calculates tripRoute and DriverMatch before constructing Ride. Only after both exist does it allocate R1, call driver.assign, and update ridesById. Expected failures return MatchStatus values.
Why A* is an extensionA* needs coordinates and a safe estimate that the confirmed model does not require.
Dijkstra uses only road minutes. A* may visit fewer nodes, but its heuristic must be designed and tested. Adding latitude and longitude now only to name-drop A* would violate the rule that every field comes from a requirement.
Build the graph and immutable route values
Read RoadGraph before Dijkstra. Its directed adjacency lists define which moves the algorithm is allowed to make. Then read Route, the immutable result shared by routing, matching, Ride, and the demo.
public record Location(String id, String name) {
public Location {
if (id == null || id.isBlank() || name == null || name.isBlank()) {
throw new IllegalArgumentException("Location id and name are required");
}
}
}public record Road(String fromId, String toId, int minutes) {
public Road {
if (fromId == null || fromId.isBlank()
|| toId == null || toId.isBlank()
|| fromId.equals(toId) || minutes <= 0) {
throw new IllegalArgumentException("Valid directed road fields are required");
}
}
}import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
public final class RoadGraph {
private final Map<String, Location> locations = new LinkedHashMap<>();
private final Map<String, List<Road>> outgoing = new LinkedHashMap<>();
public void addLocation(Location location) {
if (location == null) {
throw new IllegalArgumentException("Location is required");
}
if (locations.putIfAbsent(location.id(), location) != null) {
throw new IllegalArgumentException("Duplicate location: " + location.id());
}
outgoing.put(location.id(), new ArrayList<>());
}
public void addDirectedRoad(String fromId, String toId, int minutes) {
requireLocation(fromId);
requireLocation(toId);
outgoing.get(fromId).add(new Road(fromId, toId, minutes));
outgoing.get(fromId).sort((left, right) -> left.toId().compareTo(right.toId()));
}
public void addTwoWayRoad(String firstId, String secondId, int minutes) {
addDirectedRoad(firstId, secondId, minutes);
addDirectedRoad(secondId, firstId, minutes);
}
public boolean containsLocation(String locationId) {
return locations.containsKey(locationId);
}
public List<Road> roadsFrom(String locationId) {
requireLocation(locationId);
return List.copyOf(outgoing.get(locationId));
}
private void requireLocation(String locationId) {
if (!locations.containsKey(locationId)) {
throw new IllegalArgumentException("Unknown location: " + locationId);
}
}
}import java.util.List;
public record Route(List<String> locationIds, int totalMinutes) {
public Route {
if (locationIds == null || locationIds.isEmpty() || totalMinutes < 0) {
throw new IllegalArgumentException("Valid route fields are required");
}
locationIds = List.copyOf(locationIds);
}
}Implement routing as a complete Strategy
Read the main loop from the priority-queue removal through edge relaxation. Path reconstruction is deliberately separate so the loop answers one question at a time.
import java.util.Optional;
public interface RoutingStrategy {
Optional<Route> route(
RoadGraph graph,
String startId,
String targetId
);
}import java.util.ArrayList;
import java.util.Collections;
import java.util.Comparator;
import java.util.HashMap;
import java.util.HashSet;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import java.util.PriorityQueue;
import java.util.Set;
public final class DijkstraRoutingStrategy implements RoutingStrategy {
private record QueueEntry(String locationId, int minutes) {}
@Override
public Optional<Route> route(
RoadGraph graph,
String startId,
String targetId
) {
if (graph == null || !graph.containsLocation(startId)
|| !graph.containsLocation(targetId)) {
return Optional.empty();
}
if (startId.equals(targetId)) {
return Optional.of(new Route(List.of(startId), 0));
}
Map<String, Integer> distance = new HashMap<>();
Map<String, String> previous = new HashMap<>();
Set<String> settled = new HashSet<>();
PriorityQueue<QueueEntry> frontier = new PriorityQueue<>(
Comparator.comparingInt(QueueEntry::minutes)
.thenComparing(QueueEntry::locationId)
);
distance.put(startId, 0);
frontier.add(new QueueEntry(startId, 0));
while (!frontier.isEmpty()) {
QueueEntry current = frontier.remove();
if (current.minutes() != distance.getOrDefault(
current.locationId(), Integer.MAX_VALUE)) {
continue;
}
if (!settled.add(current.locationId())) {
continue;
}
if (current.locationId().equals(targetId)) {
return Optional.of(new Route(
rebuildPath(previous, startId, targetId),
current.minutes()
));
}
for (Road road : graph.roadsFrom(current.locationId())) {
if (settled.contains(road.toId())) {
continue;
}
int candidate = Math.addExact(current.minutes(), road.minutes());
int known = distance.getOrDefault(road.toId(), Integer.MAX_VALUE);
if (candidate < known) {
distance.put(road.toId(), candidate);
previous.put(road.toId(), current.locationId());
frontier.add(new QueueEntry(road.toId(), candidate));
}
}
}
return Optional.empty();
}
private List<String> rebuildPath(
Map<String, String> previous,
String startId,
String targetId
) {
List<String> reversed = new ArrayList<>();
String current = targetId;
reversed.add(current);
while (!current.equals(startId)) {
current = previous.get(current);
if (current == null) {
throw new IllegalStateException("Predecessor chain is incomplete");
}
reversed.add(current);
}
Collections.reverse(reversed);
return List.copyOf(reversed);
}
}Add rider, driver, request, and lifecycle state
Driver methods keep availability changes beside Driver state. Ride methods do the same for lifecycle. Neither class knows how graph routing works.
public enum DriverStatus {
AVAILABLE,
BUSY
}public final class Driver {
private final String id;
private final String name;
private String currentLocationId;
private DriverStatus status = DriverStatus.AVAILABLE;
public Driver(String id, String name, String currentLocationId) {
if (id == null || id.isBlank() || name == null || name.isBlank()
|| currentLocationId == null || currentLocationId.isBlank()) {
throw new IllegalArgumentException("Driver fields are required");
}
this.id = id;
this.name = name;
this.currentLocationId = currentLocationId;
}
public String id() { return id; }
public String name() { return name; }
public String currentLocationId() { return currentLocationId; }
public DriverStatus status() { return status; }
public boolean assign() {
if (status != DriverStatus.AVAILABLE) {
return false;
}
status = DriverStatus.BUSY;
return true;
}
public boolean completeAt(String destinationId) {
if (status != DriverStatus.BUSY
|| destinationId == null || destinationId.isBlank()) {
return false;
}
currentLocationId = destinationId;
status = DriverStatus.AVAILABLE;
return true;
}
}public record Rider(String id, String name) {
public Rider {
if (id == null || id.isBlank() || name == null || name.isBlank()) {
throw new IllegalArgumentException("Rider fields are required");
}
}
}public record RideRequest(
String riderId,
String pickupId,
String destinationId
) {
public RideRequest {
if (riderId == null || riderId.isBlank()
|| pickupId == null || pickupId.isBlank()
|| destinationId == null || destinationId.isBlank()) {
throw new IllegalArgumentException("Ride request fields are required");
}
}
}public enum RideStatus {
MATCHED,
IN_PROGRESS,
COMPLETED
}public final class Ride {
private final String id;
private final String riderId;
private final Driver driver;
private final String pickupId;
private final String destinationId;
private final Route pickupRoute;
private final Route tripRoute;
private RideStatus status = RideStatus.MATCHED;
public Ride(
String id,
String riderId,
Driver driver,
String pickupId,
String destinationId,
Route pickupRoute,
Route tripRoute
) {
if (id == null || id.isBlank() || riderId == null || riderId.isBlank()
|| driver == null || pickupId == null || pickupId.isBlank()
|| destinationId == null || destinationId.isBlank()
|| pickupRoute == null || tripRoute == null) {
throw new IllegalArgumentException("Ride fields are required");
}
this.id = id;
this.riderId = riderId;
this.driver = driver;
this.pickupId = pickupId;
this.destinationId = destinationId;
this.pickupRoute = pickupRoute;
this.tripRoute = tripRoute;
}
public String id() { return id; }
public String riderId() { return riderId; }
public Driver driver() { return driver; }
public String pickupId() { return pickupId; }
public String destinationId() { return destinationId; }
public Route pickupRoute() { return pickupRoute; }
public Route tripRoute() { return tripRoute; }
public RideStatus status() { return status; }
public boolean start() {
if (status != RideStatus.MATCHED) {
return false;
}
status = RideStatus.IN_PROGRESS;
return true;
}
public boolean complete() {
if (status != RideStatus.IN_PROGRESS) {
return false;
}
status = RideStatus.COMPLETED;
return true;
}
}Implement driver matching without hiding routing
DriverMatch keeps the selected Driver beside the pickup Route that justified the selection. The matcher is deterministic: minutes first, then driver ID.
public record DriverMatch(Driver driver, Route pickupRoute) {
public DriverMatch {
if (driver == null || pickupRoute == null) {
throw new IllegalArgumentException("Driver and pickup route are required");
}
}
}import java.util.List;
import java.util.Optional;
public interface DriverMatchingStrategy {
Optional<DriverMatch> match(
List<Driver> drivers,
String pickupId,
RoadGraph graph,
RoutingStrategy routing
);
}import java.util.ArrayList;
import java.util.Comparator;
import java.util.List;
import java.util.Optional;
public final class NearestDriverMatchingStrategy
implements DriverMatchingStrategy {
@Override
public Optional<DriverMatch> match(
List<Driver> drivers,
String pickupId,
RoadGraph graph,
RoutingStrategy routing
) {
List<DriverMatch> candidates = new ArrayList<>();
for (Driver driver : drivers) {
if (driver.status() != DriverStatus.AVAILABLE) {
continue;
}
routing.route(graph, driver.currentLocationId(), pickupId)
.ifPresent(route -> candidates.add(new DriverMatch(driver, route)));
}
return candidates.stream().min(
Comparator.comparingInt(
(DriverMatch match) -> match.pickupRoute().totalMinutes())
.thenComparing(match -> match.driver().id())
);
}
}Add explicit results and the coordinator
Expected rejection is returned as data. MatchResult contains a Ride only for MATCHED. Ride actions use another enum because “unknown ride” and “wrong state” are normal caller mistakes.
public enum MatchStatus {
MATCHED,
UNKNOWN_RIDER,
INVALID_REQUEST,
UNKNOWN_LOCATION,
UNREACHABLE_TRIP,
NO_DRIVER_AVAILABLE
}import java.util.Optional;
public record MatchResult(MatchStatus status, Optional<Ride> ride) {
public MatchResult {
if (status == null || ride == null) {
throw new IllegalArgumentException("Result fields are required");
}
}
public static MatchResult accepted(Ride ride) {
return new MatchResult(MatchStatus.MATCHED, Optional.of(ride));
}
public static MatchResult rejected(MatchStatus status) {
return new MatchResult(status, Optional.empty());
}
}public enum RideActionStatus {
STARTED,
COMPLETED,
RIDE_NOT_FOUND,
INVALID_STATE
}import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Optional;
public final class RideService {
private final Map<String, Rider> ridersById = new LinkedHashMap<>();
private final List<Driver> drivers;
private final Map<String, Ride> ridesById = new LinkedHashMap<>();
private final RoadGraph graph;
private final RoutingStrategy routing;
private final DriverMatchingStrategy matching;
private int nextRideNumber = 1;
public RideService(
List<Rider> riders,
List<Driver> drivers,
RoadGraph graph,
RoutingStrategy routing,
DriverMatchingStrategy matching
) {
if (riders == null || riders.isEmpty()
|| drivers == null || drivers.isEmpty()
|| graph == null || routing == null || matching == null) {
throw new IllegalArgumentException("Service dependencies are required");
}
for (Rider rider : riders) {
if (ridersById.putIfAbsent(rider.id(), rider) != null) {
throw new IllegalArgumentException("Duplicate rider: " + rider.id());
}
}
this.drivers = List.copyOf(drivers);
this.graph = graph;
this.routing = routing;
this.matching = matching;
}
public MatchResult requestRide(RideRequest request) {
if (request == null) {
return MatchResult.rejected(MatchStatus.INVALID_REQUEST);
}
if (!ridersById.containsKey(request.riderId())) {
return MatchResult.rejected(MatchStatus.UNKNOWN_RIDER);
}
if (!graph.containsLocation(request.pickupId())
|| !graph.containsLocation(request.destinationId())) {
return MatchResult.rejected(MatchStatus.UNKNOWN_LOCATION);
}
if (request.pickupId().equals(request.destinationId())) {
return MatchResult.rejected(MatchStatus.INVALID_REQUEST);
}
Optional<Route> tripRoute = routing.route(
graph, request.pickupId(), request.destinationId());
if (tripRoute.isEmpty()) {
return MatchResult.rejected(MatchStatus.UNREACHABLE_TRIP);
}
Optional<DriverMatch> match = matching.match(
drivers, request.pickupId(), graph, routing);
if (match.isEmpty()) {
return MatchResult.rejected(MatchStatus.NO_DRIVER_AVAILABLE);
}
DriverMatch selected = match.get();
String rideId = "R" + nextRideNumber;
Ride ride = new Ride(
rideId,
request.riderId(),
selected.driver(),
request.pickupId(),
request.destinationId(),
selected.pickupRoute(),
tripRoute.get()
);
if (!selected.driver().assign()) {
return MatchResult.rejected(MatchStatus.NO_DRIVER_AVAILABLE);
}
nextRideNumber++;
ridesById.put(ride.id(), ride);
return MatchResult.accepted(ride);
}
public RideActionStatus startRide(String rideId) {
Ride ride = ridesById.get(rideId);
if (ride == null) {
return RideActionStatus.RIDE_NOT_FOUND;
}
return ride.start()
? RideActionStatus.STARTED
: RideActionStatus.INVALID_STATE;
}
public RideActionStatus completeRide(String rideId) {
Ride ride = ridesById.get(rideId);
if (ride == null) {
return RideActionStatus.RIDE_NOT_FOUND;
}
if (ride.status() != RideStatus.IN_PROGRESS) {
return RideActionStatus.INVALID_STATE;
}
if (!ride.complete()) {
return RideActionStatus.INVALID_STATE;
}
if (!ride.driver().completeAt(ride.destinationId())) {
throw new IllegalStateException("Ride and driver state disagree");
}
return RideActionStatus.COMPLETED;
}
public Optional<Ride> ride(String rideId) {
return Optional.ofNullable(ridesById.get(rideId));
}
public List<Driver> drivers() {
return drivers;
}
}Run the same map as the simulator
The one-way G → F road makes the trip valid from G, while no driver can enter G. This lets the demo prove NO_DRIVER_AVAILABLE without confusing it with an unreachable trip.
import java.util.List;
public final class RideSharingDemo {
public static void main(String[] args) {
RoadGraph graph = sampleGraph();
Driver maya = new Driver("D1", "Maya", "B");
Driver leo = new Driver("D2", "Leo", "E");
Driver noor = new Driver("D3", "Noor", "A");
RideService service = new RideService(
List.of(new Rider("U1", "Riya")),
List.of(maya, leo, noor),
graph,
new DijkstraRoutingStrategy(),
new NearestDriverMatchingStrategy()
);
MatchResult matched = service.requestRide(
new RideRequest("U1", "D", "F")
);
Ride ride = matched.ride().orElseThrow();
System.out.println("match status: " + matched.status());
System.out.println("driver: " + ride.driver().id());
System.out.println("pickup: " + ride.pickupRoute().locationIds()
+ " = " + ride.pickupRoute().totalMinutes());
System.out.println("trip: " + ride.tripRoute().locationIds()
+ " = " + ride.tripRoute().totalMinutes());
System.out.println("start: " + service.startRide(ride.id()));
System.out.println("complete: " + service.completeRide(ride.id()));
System.out.println("driver after trip: " + leo.currentLocationId()
+ " / " + leo.status());
boolean noSecondRideBeforeRequest = service.ride("R2").isEmpty();
MatchResult unreachablePickup = service.requestRide(
new RideRequest("U1", "G", "F")
);
System.out.println("unreachable pickup: " + unreachablePickup.status());
System.out.println("no new ride: "
+ (noSecondRideBeforeRequest && service.ride("R2").isEmpty()));
}
private static RoadGraph sampleGraph() {
RoadGraph graph = new RoadGraph();
graph.addLocation(new Location("A", "West"));
graph.addLocation(new Location("B", "Market"));
graph.addLocation(new Location("C", "Park"));
graph.addLocation(new Location("D", "Museum"));
graph.addLocation(new Location("E", "Harbor"));
graph.addLocation(new Location("F", "Airport"));
graph.addLocation(new Location("G", "Hill"));
graph.addTwoWayRoad("A", "B", 4);
graph.addTwoWayRoad("A", "C", 2);
graph.addTwoWayRoad("C", "B", 1);
graph.addTwoWayRoad("B", "D", 5);
graph.addTwoWayRoad("C", "D", 8);
graph.addTwoWayRoad("C", "E", 10);
graph.addTwoWayRoad("D", "E", 2);
graph.addTwoWayRoad("D", "F", 6);
graph.addTwoWayRoad("E", "F", 3);
graph.addDirectedRoad("G", "F", 1);
return graph;
}
}- 1Initial stateMaya is AVAILABLE at B, Leo at E, Noor at A, and no ride ID exists.
- 2Trip validationDijkstra returns D → E → F in 5 minutes, so the requested trip is possible.
- 3Driver comparisonPickup routes cost Maya 5, Leo 2, and Noor 8 minutes. NearestDriverMatchingStrategy chooses Leo.
- 4MutationRideService creates R1, stores both routes, calls Leo.assign(), and records R1 as MATCHED.
- 5LifecyclestartRide returns STARTED. completeRide returns COMPLETED, moves Leo to F, and makes him AVAILABLE.
- 6RejectionG → F is a valid trip, but no driver can reach G. requestRide returns NO_DRIVER_AVAILABLE; R2 is not created and no driver changes.
5. Extensions (~5 minutes)
“Now include live traffic”
The changed requirement alters edge weights while rides are being requested. RoadGraph currently owns static minutes. Introduce a traffic-aware weight provider or immutable graph snapshot used by one routing call. RoutingStrategy still owns path finding; do not let DriverMatchingStrategy read traffic feeds directly.
“Use A* on a large map”
The affected rule is route search. Add coordinates to Location, define a safe travel-time heuristic, and implement AStarRoutingStrategy behind the existing interface. Keep Dijkstra as a reference and test that the heuristic never overestimates on the chosen road model.
“Find drivers efficiently across a city”
The naive matcher runs a route for every available driver. Add a geographic index to find a bounded candidate set, then run exact road routing only for those drivers. The index narrows candidates; it does not replace final routing correctness.
“Allow two riders to request at the same time”
The current sequential assumption prevents both requests from seeing one driver as AVAILABLE. In one Java process, protect candidate selection plus assign as one critical operation. Across servers, use a transactional or compare-and-set reservation in shared storage. A local lock alone cannot coordinate separate processes.
“Let a driver accept or reject”
Ride currently enters MATCHED immediately. Add OFFERED and an offer timeout. The driver's temporary reservation belongs beside the offer state, and a rejection releases the driver before the next matching attempt. This is when a richer State design may become useful.
“Add pricing and payment”
Fare calculation depends on distance, time, vehicle, and pricing policy; payment is a separate external workflow. Add a PricingStrategy only after its inputs and variations are confirmed. Keep payment failure from corrupting ride routing or driver availability.
A strong spoken summary
“I separated matching from routing. RoadGraph owns directed weighted edges. DijkstraRoutingStrategy returns the cheapest path using distance estimates, predecessors, a settled set, and a priority queue. NearestDriverMatchingStrategy calls routing for AVAILABLE drivers and compares pickup minutes, then ID. RideService validates the trip and match before creating a Ride or making a Driver busy. The first version uses static graph minutes and sequential in-memory calls; live traffic, A*, geographic indexing, and concurrent driver reservation are extensions.”