Building a Cab Booking System with Ride State Machine

GPS-based nearby search, Haversine distance, fare calculation, and race-safe ride acceptance — all backed by PostgreSQL

By SysAdmin · Published 2026-05-27

Building a Cab Booking System with Ride State Machine

Uber, Ola, Lyft — every ride-hailing platform must solve the same four problems: find nearby drivers, prevent double-acceptance, enforce ride state transitions, and calculate fares. This is the system design behind all of them.

1. The Problem

Build a cab booking system that supports:

2. The Naïve Approach (and Why It Fails)

Map<String, Cab> cabs = new ConcurrentHashMap<>();
Map<String, Ride> rides = new ConcurrentHashMap<>();

public boolean acceptRide(String rideId, String cabId) {
    Ride ride = rides.get(rideId);
    if (ride.status == REQUESTED) {
        ride.status = ACCEPTED;
        ride.cabId = cabId;
        return true;
    }
    return false;
}

This breaks because:

  1. Race condition — Two drivers call acceptRide simultaneously, both see REQUESTED, both succeed. The rider now has two drivers.
  2. Data loss — Server restart loses all rides and cabs (in-memory only)
  3. No geographic search — Without Haversine, you'd need to iterate all cabs and compute Euclidean distance (wrong on a sphere!)

3. The Right Model

Two PostgreSQL tables:

CREATE TABLE cabs (
    cab_id VARCHAR(64) PRIMARY KEY,
    driver_name VARCHAR(128),
    vehicle_number VARCHAR(64),
    vehicle_type VARCHAR(32),
    current_lat DOUBLE PRECISION,
    current_lng DOUBLE PRECISION,
    status VARCHAR(16) DEFAULT 'AVAILABLE'  -- AVAILABLE | ON_TRIP
);

CREATE TABLE rides (
    ride_id VARCHAR(64) PRIMARY KEY,
    rider_name VARCHAR(128),
    pickup_lat DOUBLE PRECISION,
    pickup_lng DOUBLE PRECISION,
    drop_lat DOUBLE PRECISION,
    drop_lng DOUBLE PRECISION,
    cab_id VARCHAR(64),
    status VARCHAR(16) DEFAULT 'REQUESTED',
    created_at BIGINT
);

The state machine:

REQUESTED ──→ ACCEPTED ──→ COMPLETED
    │
    └──────→ CANCELLED

4. The Implementation, Walked Through

The Interface

public interface CabBookingContract {
    String registerCab(String driverName, String vehicleNumber,
                       String vehicleType, double lat, double lng);
    String requestRide(String riderName, double pickupLat, double pickupLng,
                       double dropLat, double dropLng);
    boolean acceptRide(String rideId, String cabId);
    boolean completeRide(String rideId);
    boolean cancelRide(String rideId);
    Map<String, Object> getRideDetails(String rideId);
    boolean updateCabLocation(String cabId, double lat, double lng);
    List<Map<String, Object>> findAvailableCabs(double lat, double lng, double radiusKm);
    List<Map<String, Object>> getDriverHistory(String cabId);
    double calculateFare(String rideId);
}

Race-Safe Ride Acceptance

The key insight: use an atomic UPDATE ... WHERE status = 'REQUESTED' inside a transaction.

public boolean acceptRide(String rideId, String cabId) {
    return db.transaction(tx -> {
        int updated = tx.update(
            "UPDATE rides SET status='ACCEPTED', cab_id=? " +
            "WHERE ride_id=? AND status='REQUESTED'",
            cabId, rideId);
        if (updated == 0) return false;  // already accepted or cancelled
        tx.update("UPDATE cabs SET status='ON_TRIP' WHERE cab_id=?", cabId);
        return true;
    });
}

If two drivers race, the WHERE status='REQUESTED' clause means only the first UPDATE changes a row. The second sees updated == 0 and returns false.

The Haversine Formula

Computes the great-circle distance between two GPS coordinates on a sphere:

private double haversine(double lat1, double lng1, double lat2, double lng2) {
    double R = 6371.0; // Earth's radius in km
    double dLat = Math.toRadians(lat2 - lat1);
    double dLng = Math.toRadians(lng2 - lng1);
    double a = Math.sin(dLat / 2) * Math.sin(dLat / 2)
             + Math.cos(Math.toRadians(lat1)) * Math.cos(Math.toRadians(lat2))
             * Math.sin(dLng / 2) * Math.sin(dLng / 2);
    return R * 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a));
}

Nearby Cab Search

Fetch all AVAILABLE cabs, compute Haversine distance, filter by radius, sort by distance:

public List<Map<String, Object>> findAvailableCabs(double lat, double lng, double radiusKm) {
    List<Map<String, Object>> cabs = db.query(
        "SELECT cab_id, driver_name, vehicle_type, current_lat, current_lng " +
        "FROM cabs WHERE status = 'AVAILABLE'");
    return cabs.stream()
        .map(row -> {
            double dist = haversine(lat, lng, (double)row.get("current_lat"),
                                               (double)row.get("current_lng"));
            Map<String, Object> r = new LinkedHashMap<>();
            r.put("cabId", row.get("cab_id"));
            r.put("driverName", row.get("driver_name"));
            r.put("vehicleType", row.get("vehicle_type"));
            r.put("distanceKm", dist);
            return r;
        })
        .filter(m -> (double)m.get("distanceKm") <= radiusKm)
        .sorted(Comparator.comparingDouble(m -> (double)m.get("distanceKm")))
        .collect(Collectors.toList());
}

💡 For production with millions of cabs, you'd use PostGIS spatial indexes. But at LLD scale (hundreds of cabs), scanning all AVAILABLE cabs is fast enough.

Fare Calculation

public double calculateFare(String rideId) {
    Map<String, Object> ride = getRideDetails(rideId);
    if (ride == null) return -1.0;
    double dist = haversine(
        (double) ride.get("pickupLat"), (double) ride.get("pickupLng"),
        (double) ride.get("dropLat"),   (double) ride.get("dropLng"));
    return 50.0 + dist * 15.0;  // baseFare + perKmRate
}

5. Performance + Concurrency

6. What the Grader Checks

TestWhat It Verifies
testRegisterAndFindCabCab registration + findAvailableCabs returns it
testRequestAndAcceptRideBasic ride lifecycle REQUESTED → ACCEPTED
testDoubleAcceptRaceOnly first acceptRide succeeds, second returns false
testCompleteRideACCEPTED → COMPLETED, cab becomes AVAILABLE again
testCancelRideREQUESTED → CANCELLED works; can't cancel ACCEPTED rides
testHaversineDistanceNearby search uses correct geographic distance
testFareCalculationFare = 50 + (Haversine km × 15)
testDriverHistoryCompleted/cancelled rides appear in driver history
testUpdateCabLocationGPS update reflected in subsequent findAvailableCabs

7. Takeaways

  1. State machines should be enforced at the database level, not the application level. The WHERE status='REQUESTED' pattern eliminates an entire class of concurrency bugs without distributed locks.
  1. The Haversine formula is essential for any geo-aware system. Euclidean distance on latitude/longitude coordinates is wrong — 1° of longitude is ~111km at the equator but ~0km at the poles.
  1. Keep cab status and ride status synchronized in a transaction. If you update the ride but crash before updating the cab, you have a ghost cab that's \\"available\\" but actually on a trip. Transactions make this impossible.

👉 Try it yourself: Cab Booking on Cruscible