Capstone: Ride-Sharing App
A full interview-style system design walkthrough: build a ride-sharing app the obvious way, find its real bugs and SOLID violations, then refactor it end to end using Strategy, Observer, and Mediator together.
Learning objectives
- Identify SRP, OCP, LSP, ISP, and DIP violations in a working but poorly structured design
- Refactor a monolithic service into focused classes connected by composition and polymorphism
- Recognize where Strategy, Observer, and Mediator each solve a distinct problem within one coherent system
- Reason about production concerns a clean design still has to account for, such as scale and lifecycle simulation
◆ The problem
Design a ride-sharing application. Passengers can request a ride by providing a destination distance. The system should find the nearest available driver, support multiple vehicle types (car, bike, and more added later), support multiple fare calculation strategies (standard, shared, luxury), and notify both the passenger and the driver as the ride's status changes from scheduled to ongoing to completed. Use sound object-oriented principles and appropriate design patterns.
This prompt is deliberately open-ended, the way a real interview prompt usually is. The right first move is not to sit down and design the "correct" architecture from scratch. It is to build the most direct thing that satisfies the stated requirements, and let its own weight show exactly where it breaks — because a design's real weaknesses are much easier to spot once there is working code to point at, rather than trying to anticipate every problem in the abstract before writing anything.
This chapter follows that path deliberately. First, a version that works but is built carelessly. Then, a real bug that surfaces the moment it is actually exercised. Then, every principle violation named specifically, one at a time. Then, a full refactor, class by class, until the final design cleanly separates every responsibility the prompt actually asks for — and several patterns covered earlier turn out to already be doing real work inside it, without ever being named explicitly in the requirements.
Start with the entities the requirements name directly: drivers, passengers, and a service that matches them. Put the logic wherever it is first needed, and see what happens.
Location holds a latitude and longitude with no other behavior. Vehicle holds a number plate and a raw type string — "car" or "bike" — checked later with if/else. Driver and Passenger each hold a name and a location, with no shared parent. RideSharingAppService does everything else: it stores the list of drivers and passengers, finds the nearest driver with a brute-force loop, computes distance with a private helper method, computes fare with a private helper that branches on the vehicle's type string, and prints the result.
Run a quick client against it, and the core flow genuinely works: booking a ride finds the nearest driver and prints a fare. But book a second ride for the same passenger right after the first, and the same driver gets assigned again — even though, in the story this code is telling, that driver should still be out on the first ride. Nothing in drivers ever removes a driver once they are booked, so the list never shrinks and never reflects who is actually available. This is a real, live bug surfaced by actually running the code, not a hypothetical one — and it is worth sitting with before even asking whether the design is well-structured, because it is exactly the kind of thing that only becomes visible once code this direct actually runs.
Naming every problem in this one class, one at a time: it manages the driver repository, manages the passenger repository, does ride matching, does distance calculation, and does fare calculation — five distinct jobs, five distinct reasons this one class could need to change, a direct single-responsibility violation. Supporting a new vehicle type means editing the fare calculation's if/else chain directly, reopening tested code every time the vehicle lineup grows — a violation of keeping existing code closed to modification. Vehicle is one concrete class holding a raw type string checked with string equality; there is no real hierarchy here at all, which is exactly why the fare logic has to ask "what does this string say" instead of simply calling a method on the object. Matching logic, fare logic, and eventual notification logic have no separation into focused interfaces at all — everything is one undifferentiated method soup on one class. And the high-level matching service depends directly on the low-level details of how fare is computed, so a second fare-calculation approach — surge pricing, a discount — would have to be edited directly inside the same class that is also responsible for matching drivers to passengers.
💻 Code example
class Location { double latitude, longitude; Location(double latitude, double longitude) { this.latitude = latitude; this.longitude = longitude; } } class Vehicle { String numberPlate; String type; // "car", "bike" -- just a string, checked later with if/else Vehicle(String numberPlate, String type) { this.numberPlate = numberPlate; this.type = type; } } class Driver { String name; Location location; Vehicle vehicle; // constructor, getters omitted for brevity } class Passenger { String name; Location location; } class RideSharingAppService { private List<Driver> drivers = new ArrayList<>(); private List<Passenger> passengers = new ArrayList<>(); void addDriver(Driver d) { drivers.add(d); } void addPassenger(Passenger p) { passengers.add(p); } void bookRide(Passenger passenger, double distance) { if (drivers.isEmpty()) { System.out.println("No drivers available for " + passenger.name); return; } Driver assignedDriver = null; double minDistance = Double.MAX_VALUE; for (Driver driver : drivers) { // brute-force nearest-driver search -- O(n), fine for now double d = calculateDistance(passenger.location, driver.location); if (d < minDistance) { minDistance = d; assignedDriver = driver; } } double fare = calculateFare(assignedDriver.vehicle, distance); System.out.println("Ride booked for " + passenger.name + " with " + assignedDriver.name + " for a fare of " + fare); } private double calculateDistance(Location a, Location b) { // Euclidean distance -- lives inside the service, not Location double dx = a.latitude - b.latitude, dy = a.longitude - b.longitude; return Math.sqrt(dx * dx + dy * dy); } private double calculateFare(Vehicle vehicle, double distance) { if (vehicle.type.equals("car")) return distance * 20; // hardcoded per-km rates, keyed off a raw string else if (vehicle.type.equals("bike")) return distance * 10; else return distance * 8; // unknown type -- a silent, arbitrary guess } } // a quick client to test it RideSharingAppService app = new RideSharingAppService(); app.addDriver(driver1); // e.g. "Lee", a car app.addDriver(driver2); // e.g. "Alice", a bike app.bookRide(passenger1, 10); // "Ride booked for John with Lee for a fare of 200" app.bookRide(passenger1, 10); // booking AGAIN -- Lee gets assigned a second time, while still on the first ride
With every violation named, the fix for each one follows fairly directly from asking what each piece of state or behavior actually belongs to.
Distance is a property of two locations, not of the matching service — it belongs on Location itself, as a method that takes another Location and returns the distance between them. Driver and Passenger share real, common state: a name, an email, a location. That belongs in a shared parent class, the same instinct that puts any genuinely shared fields and behavior into a common superclass rather than duplicating them. A vehicle's fare-per-kilometer is a property of its specific type, not a string to branch on — that is a genuine "each subtype behaves differently" situation, exactly what polymorphism exists to solve, so Vehicle should become an abstract class with a real abstract method rather than a concrete class holding a label.
Which fare strategy applies — standard, shared, or luxury — is a separate axis entirely from which vehicle was assigned. Those are two independent things that both affect the final price, and keeping them independent means any vehicle can be combined with any strategy without either one needing to know the other exists. A ride itself — its passenger, driver, distance, fare, and status — is a real thing worth its own class, not a handful of loose values passed around between methods. And the matching service's only real job should be matching and coordinating, not computing distances, not computing fares, not deciding how notifications get formatted or sent.
Building this out, Location gains a calculateDistanceFrom() method, moving the distance formula off the service and onto the thing it is actually about. Any future class that needs a distance between two points can now call location1.calculateDistanceFrom(location2) directly, instead of duplicating the formula or depending on the matching service just to do arithmetic. User becomes an abstract parent for Driver and Passenger, holding the shared name, email, and location fields, and declaring one abstract method, notify(String message) — every user must be notifiable, but exactly how is left to each subclass to decide. Driver adds the one field that actually makes it distinct: a Vehicle. Passenger adds nothing beyond what User already provides.
💻 Code example
class Location { private final double latitude, longitude; Location(double latitude, double longitude) { this.latitude = latitude; this.longitude = longitude; } double calculateDistanceFrom(Location other) { // was a static helper on the service -- now a method ON the thing it's about double dx = this.latitude - other.latitude; double dy = this.longitude - other.longitude; return Math.sqrt(dx * dx + dy * dy); } } abstract class User { protected String name; protected String email; protected Location location; // protected -- Driver and Passenger need direct access User(String name, String email, Location location) { this.name = name; this.email = email; this.location = location; } Location getLocation() { return location; } void setLocation(Location location) { this.location = location; } abstract void notify(String message); // every user must be notifiable, but HOW is left to each subclass } class Driver extends User { private final Vehicle vehicle; // the ONE thing a Driver has that a Passenger doesn't Driver(String name, String email, Location location, Vehicle vehicle) { super(name, email, location); this.vehicle = vehicle; } Vehicle getVehicle() { return vehicle; } public void notify(String message) { System.out.println("[Driver] " + name + ": " + message); } } class Passenger extends User { Passenger(String name, String email, Location location) { super(name, email, location); } public void notify(String message) { System.out.println("[Passenger] " + name + ": " + message); } }
The raw type string on Vehicle is replaced with a genuine abstract method: getFarePerKilometer(). Car and Bike each implement it with their own rate, and adding a new vehicle type — an auto-rickshaw, say — is now a five-line class, with nothing that already exists touched at all.
Vehicle now answers "how much does this specific vehicle cost per kilometer," but the requirements ask for something orthogonal: which pricing scheme applies. That is a second, independent axis of variation, and it is a direct application of the Strategy pattern — one FareStrategy interface, with StandardFareStrategy, SharedFareStrategy, and LuxuryFareStrategy as interchangeable implementations. Notice the two hierarchies stay genuinely independent: Vehicle knows only its own base rate, and FareStrategy knows only how to adjust that rate. A Bike ridden under SharedFareStrategy and a Car ridden under LuxuryFareStrategy both just work, with neither hierarchy aware the other exists.
Ride bundles a passenger, a driver, a distance, a chosen fare strategy, a computed fare, and a status that changes over time into one real class, instead of passing loose values around between methods. Its calculateFare() delegates to whichever strategy was supplied. Its updateStatus() does something worth pausing on: every time status changes, it notifies both the passenger and the driver automatically, through the same notify() method each inherited from User.
◆ Under the hood
That notification step is the Observer pattern in its simplest possible form, even though neither class here is named "Observer." Ride is the subject; passenger and driver are its observers, each polymorphically notified through the same inherited notify() method the moment the ride's status changes. It does not need a formal Observer interface or an attach()/detach() pair to count as the pattern — the underlying idea, one state change automatically informing several dependents, is genuinely present.
Finally, RideMatchingService becomes the one class that holds references to both drivers and passengers and coordinates between them, while Driver and Passenger never reference each other directly. That is the Mediator pattern: a dedicated coordinator sitting between two kinds of objects so that neither needs to know the other exists.
💻 Code example
abstract class Vehicle { protected String numberPlate; Vehicle(String numberPlate) { this.numberPlate = numberPlate; } abstract double getFarePerKilometer(); // the type string is GONE -- this is what replaces it } class Car extends Vehicle { Car(String numberPlate) { super(numberPlate); } double getFarePerKilometer() { return 20; } } class Bike extends Vehicle { Bike(String numberPlate) { super(numberPlate); } double getFarePerKilometer() { return 10; } } interface FareStrategy { double calculateFare(Vehicle vehicle, double distance); } class StandardFareStrategy implements FareStrategy { public double calculateFare(Vehicle vehicle, double distance) { return vehicle.getFarePerKilometer() * distance; // no if/else on vehicle type -- the vehicle answers for itself } } class SharedFareStrategy implements FareStrategy { public double calculateFare(Vehicle vehicle, double distance) { return vehicle.getFarePerKilometer() * distance * 0.5; // 50% off, for a shared ride } } class LuxuryFareStrategy implements FareStrategy { public double calculateFare(Vehicle vehicle, double distance) { return vehicle.getFarePerKilometer() * distance * 1.5; // 50% surcharge } } enum RideStatus { SCHEDULED, ONGOING, COMPLETED } class Ride { private final Passenger passenger; private final Driver driver; private final double distance; private final FareStrategy fareStrategy; private double fare; private RideStatus status; Ride(Passenger passenger, Driver driver, double distance, FareStrategy fareStrategy) { this.passenger = passenger; this.driver = driver; this.distance = distance; this.fareStrategy = fareStrategy; this.status = RideStatus.SCHEDULED; // every ride starts here } void calculateFare() { this.fare = fareStrategy.calculateFare(driver.getVehicle(), distance); // polymorphic: which strategy, which vehicle, both resolved at runtime } double getFare() { return fare; } void updateStatus(RideStatus status) { this.status = status; notifyUsers(status); // every status change reaches BOTH watchers, automatically } private void notifyUsers(RideStatus status) { passenger.notify("Your ride is " + status); // this is the Observer pattern, even though neither class is named "Observer" driver.notify("Your ride is " + status); } }
RideMatchingService ends up genuinely thin, because every other class now owns the piece of logic it should own. It keeps a list of available drivers, adds drivers to that list, and exposes one real operation: requestRide(passenger, distance, fareStrategy). That method finds the nearest available driver using Location.calculateDistanceFrom(), removes that driver from the available list — which directly fixes the earlier bug, since a booked driver can no longer be handed out to a second request — constructs a Ride, calculates its fare, notifies both parties, walks the ride through its status transitions, and finally returns the driver to the available pool once the ride completes.
Compare this to the very first version: it no longer computes distances itself, no longer computes fares itself, and no longer manages a passenger repository at all, since nothing in the requirements actually needed one — passengers arrive already constructed, from whoever calls requestRide(). What is left is genuinely one job: match a passenger to a driver, and coordinate the resulting ride.
Tracing a real call through this design shows every earlier refactor paying off at once. A request comes in; the available driver list is checked and is not empty, so matching proceeds. Distance from the passenger to each available driver is computed by Location, not the service. The nearest driver is removed from the available list. A new Ride is constructed with status SCHEDULED. calculateFare() asks the driver's vehicle for its rate and applies the chosen strategy on top of it. Both parties are notified of the scheduled fare. The ride moves through ONGOING and then COMPLETED, notifying both parties automatically at each step through Ride's own Observer-shaped logic. And finally, the driver is added back to the available pool — the exact driver who was removed at the start of the request is the exact driver who becomes available again once their ride actually finishes.
💻 Code example
class RideMatchingService { private final List<Driver> availableDrivers = new ArrayList<>(); void addDriver(Driver driver) { availableDrivers.add(driver); } void requestRide(Passenger passenger, double distance, FareStrategy fareStrategy) { if (availableDrivers.isEmpty()) { passenger.notify("No drivers are available right now"); // the earlier bug's fix starts here return; } Driver nearestDriver = findNearestDriver(passenger.getLocation()); availableDrivers.remove(nearestDriver); // FIXES the earlier bug -- a booked driver is no longer available Ride ride = new Ride(passenger, nearestDriver, distance, fareStrategy); ride.calculateFare(); passenger.notify("Ride scheduled with fare \u20b9" + ride.getFare()); nearestDriver.notify("New ride request for \u20b9" + ride.getFare()); ride.updateStatus(RideStatus.ONGOING); // simulating time passing ride.updateStatus(RideStatus.COMPLETED); availableDrivers.add(nearestDriver); // the driver becomes available again once the ride finishes } private Driver findNearestDriver(Location passengerLocation) { Driver nearest = null; double minDistance = Double.MAX_VALUE; for (Driver driver : availableDrivers) { double d = driver.getLocation().calculateDistanceFrom(passengerLocation); // called ON Location now, not a static helper if (d < minDistance) { minDistance = d; nearest = driver; } } return nearest; } } public class Client { public static void main(String[] args) { Location locationA = new Location(12.93, 77.61); Location locationB = new Location(12.97, 77.59); Location passengerLocation = new Location(12.96, 77.60); Driver raj = new Driver("Raj", "raj@example.com", locationA, new Car("KA-01-1234")); Driver alice = new Driver("Alice", "alice@example.com", locationB, new Bike("KA-05-9876")); Passenger priya = new Passenger("Priya", "priya@example.com", passengerLocation); RideMatchingService matchingService = new RideMatchingService(); matchingService.requestRide(priya, 10, new StandardFareStrategy()); // "[Passenger] Priya: No drivers are available right now" -- no drivers added yet matchingService.addDriver(raj); matchingService.addDriver(alice); matchingService.requestRide(priya, 10, new StandardFareStrategy()); } }
A clean design still leaves real production questions unanswered on purpose — worth naming explicitly rather than pretending the design is finished.
▲ Edge case
The ride lifecycle here is simulated, not real. requestRide() calls updateStatus(ONGOING) immediately followed by updateStatus(COMPLETED), with no actual time passing between them — a deliberate simplification for a self-contained example. A real system would trigger these transitions from external events, such as a driver's app confirming pickup or a GPS signal confirming arrival, not from the same method that created the ride in the first place. The shape of the fix does not change: call ride.updateStatus() whenever the real-world event actually happens. Only what triggers that call changes.
▲ Edge case
The nearest-driver search is still O(n). findNearestDriver() checks every available driver on every single request — fine at the scale of a teaching example, genuinely not fine at the scale of a real ride-sharing platform with thousands of concurrent drivers. This is a deliberate, stated simplification, not an oversight, and it is worth being able to say so out loud in an interview: a real system would index drivers spatially, using something like a quadtree or a geohash grid, so that finding the nearest driver is a fast indexed lookup rather than a linear scan over every driver in the system.
▲ Edge case
Choosing a FareStrategy is still the caller's job. requestRide() takes a FareStrategy directly from whoever calls it — there is no factory deciding "standard versus shared versus luxury" based on, say, a request parameter string. That is a deliberate choice in this design, not a gap: a factory centralizing that decision would be a natural, reasonable next step if it needed to happen based on incoming request data, rather than something this design is missing by mistake.
Q: Why does Vehicle need to become abstract, with Car and Bike as subclasses, instead of keeping one Vehicle class with a type string? A: A raw type string forces fare logic to branch with if/else on string equality -- a real design smell tied to both substitutability and closed-for-modification concerns. Making getFarePerKilometer() a genuine abstract method lets each vehicle type answer for itself, and lets a new vehicle type be added as a brand-new class with zero changes anywhere else.
Q: Why are Vehicle and FareStrategy kept as two separate hierarchies instead of one? A: They vary independently -- which vehicle was assigned and which pricing scheme applies are two unrelated decisions. Keeping them separate means any vehicle works correctly with any fare strategy, without either hierarchy ever needing to know the other exists.
Q: Where exactly does the Observer pattern show up in this design, even though no class is named "Observer"? A: Inside Ride.notifyUsers() -- when a ride's status changes, both the passenger and the driver are notified automatically through the shared User.notify() method. That is the same "one state change, many dependents informed automatically" idea the Observer pattern names formally, present here without a formal Observer interface.
Q: What specifically makes RideMatchingService a Mediator rather than just a service class? A: It is the only class in the whole design that holds references to both Driver and Passenger objects and coordinates between them -- neither Driver nor Passenger ever references the other directly. All communication between the two routes through this one coordinating class.
Q: What bug did the first working version have, and which specific lines in the refactored RideMatchingService fix it? A: A driver stayed bookable indefinitely because nothing ever removed them from the available list once assigned. availableDrivers.remove(nearestDriver) right after matching fixes that directly, and availableDrivers.add(nearestDriver) once the ride completes correctly returns the driver to the pool.
Q: Why is choosing between a Vehicle's base rate and a FareStrategy's adjustment described as two independent axes, and where else in this material does that same reasoning apply? A: Because any vehicle can be combined with any strategy -- a Bike under LuxuryFareStrategy and a Car under SharedFareStrategy are both valid, meaningful combinations. That is the same N-independent-dimensions reasoning that justifies keeping two hierarchies separate and connecting them through composition rather than folding them into one combined hierarchy.
Want a visual for this concept?
Generate a diagram tailored to “Capstone: Ride-Sharing App” — the AI picks whichever visual (flowchart, comparison, sequence, etc.) best fits.
Sign in to generate a visual →