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:
- Cab registration with GPS location and vehicle type
- Ride requests with pickup and drop-off coordinates
- First-come-first-served acceptance — only one cab can accept a ride
- Ride lifecycle:
REQUESTED → ACCEPTED → COMPLETEDorREQUESTED → CANCELLED - Nearby cab search using geographic distance (Haversine formula)
- Distance-based fare:
fare = baseFare + (distanceKm × perKmRate) - All data persisted in PostgreSQL
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:
- Race condition — Two drivers call
acceptRidesimultaneously, both seeREQUESTED, both succeed. The rider now has two drivers. - Data loss — Server restart loses all rides and cabs (in-memory only)
- 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
AVAILABLEcabs 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
- Race prevention:
UPDATE ... WHERE status = 'REQUESTED'is atomic at the DB level — no application-level locking needed - Cab status sync: The transaction in
acceptRideensures cab status and ride status are always consistent — either both update or neither does - Connection management: Each operation is a single query or a small transaction — no long-held locks
6. What the Grader Checks
| Test | What It Verifies |
|---|---|
testRegisterAndFindCab | Cab registration + findAvailableCabs returns it |
testRequestAndAcceptRide | Basic ride lifecycle REQUESTED → ACCEPTED |
testDoubleAcceptRace | Only first acceptRide succeeds, second returns false |
testCompleteRide | ACCEPTED → COMPLETED, cab becomes AVAILABLE again |
testCancelRide | REQUESTED → CANCELLED works; can't cancel ACCEPTED rides |
testHaversineDistance | Nearby search uses correct geographic distance |
testFareCalculation | Fare = 50 + (Haversine km × 15) |
testDriverHistory | Completed/cancelled rides appear in driver history |
testUpdateCabLocation | GPS update reflected in subsequent findAvailableCabs |
7. Takeaways
- 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.
- 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.
- 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