Advanced System Design Tutorial 0/70 lessons ~6 min read Lesson 53

    Design a Ride Booking Platform

    TechLearningPro runs in-person intensives in a handful of cities. Learners and instructors need a car between a venue, a hotel, and an airport, on a schedule that collapses into th

    Course progress0%
    Focus
    15 guided sections
    Practice signal
    Examples included
    Career prep
    Interview Q&A included

    Design a Ride Booking Platform

    Advanced System Design & Software Architecture · Lesson 53 · Real-World System Design Case Studies

    Lesson Overview

    TechLearningPro runs in-person intensives in a handful of cities. Learners and instructors need a car between a venue, a hotel, and an airport, on a schedule that collapses into the same ten minutes. The first version was a shared spreadsheet and a dispatcher with a phone. Two drivers were sent to one guest, and a guest who cancelled still showed as assigned. Atlas and Reel were about bytes that can be cached. This product, Dispatch, is about a scarce physical resource: one driver, one vehicle, one trip, and a location that is already wrong by the time it is stored.

    The primer at /learn/system-design/uber-ride-sharing lists geospatial indexes, matching, and surge pricing as boxes. It does not specify the conditional update that stops two riders from claiming one driver, the difference between a fare quote and a fare locked on the trip, or what the rider sees when location updates are fresh and the matcher is down. This lesson writes that packet for one metro first, with a scale path to many cities. We use public algorithms — geohash, or a hexagonal index such as the open H3 library — and ordinary HTTP. We do not call a private dispatch API of a commercial ride company, and we do not pretend to know one.

    The method is lesson 23. The hot number is location updates per second, not trip creates. The consistency story is a single trip row with legal transitions, the tool from lesson 36 applied as a conditional update rather than a lock service. Idempotency covers the rider's retry of "request a car." Payments are a port to a vendor, the same boundary as the rest of this course: authorize and capture, no card numbers in Dispatch.

    Learning Objectives

    By the end of this lesson you will be able to:

    1. Model a trip as a state machine with one writer and illegal transitions rejected by the database.
    2. Estimate location-update rate, trip-create rate, and storage, and say which one saturates first.
    3. Index driver locations for "who is near this pickup" without treating the index as the source of assignment.
    4. Distinguish a quote, a locked quote on the trip, and a final fare.
    5. Specify APIs for request, offer, accept, cancel, and complete, including idempotency and conflict codes.
    6. Explain failure when the matcher, a driver app, or the payment vendor is down, and what remains true about the trip.
    7. Keep precise location history inside a retention and access policy.
    8. Decide when one city's relational database with a geospatial index is enough, and what measurement reopens a cell-sharded matcher.

    Prerequisites

    • Lesson 23's sequence and lesson 24's arithmetic. Peak factor is labeled.
    • Lesson 28, indexing, because the geospatial lookup is an index, not a full table scan, and not magic.
    • Lesson 36, coordination. The driver's single active trip is a unique partial index plus a conditional update.
    • Lesson 37, idempotency, on trip create.
    • Lesson 32, sagas, only at the boundary of payment capture versus a trip that already happened. The ride cannot be rolled back by a database.
    • Lesson 35, rate limiting, on location spam and on repeated requests.
    • The ride-sharing primer is vocabulary. The state machine and the conditional accept are the design.

    Detailed Core Concepts

    Product boundary

    Actors are riders (learners, instructors, staff), drivers employed by or contracted to the intensive, and a city ops lead. v1 is one metro. A trip has one pickup, one drop-off, one rider account, and at most one driver.

    In scope:

    • Rider requests a trip with pickup and drop-off coordinates.
    • System produces a quote and, on confirm, a trip in matching.
    • Matcher offers the trip to a nearby available driver. The driver accepts or the offer expires.
    • Trip progresses: accepted, arriving, in_progress, completed, or cancelled.
    • Driver app sends location on an interval while on duty.
    • Fare is computed from a quote policy frozen on the trip, plus measured distance and time if the policy says so.
    • Payment authorize at accept, capture at complete, through the payments vendor.

    Out of scope:

    • Multi-hop rides, shared trips with strangers, and rental scooters.
    • A public driver marketplace and driver pricing competition.
    • Turn-by-turn navigation. The driver app may embed a maps SDK. Dispatch stores coordinates, not a route polyline it claims is authoritative. An estimate of distance for the quote can use a routing API you operate under contract; the design treats it as a port with a timeout and a fallback haversine estimate labeled as rough.
    • Nationwide matching in v1. The scale path names when a second city forces a partition.
    • Storing card data. The vendor returns a payment method id.

    Functional behavior

    Request: the rider sends pickup, drop-off, and an idempotency key. Dispatch returns a quote id, a price range or a fixed price according to policy, an expiry of one minute, and the inputs that were priced (distance estimate, product type, surge multiplier). Confirming the quote creates a trip in matching bound to that quote. A second confirm with the same idempotency key returns the same trip. Confirming an expired quote returns 409 and the client must request a new quote. The quote is not a trip. This split lets the rider see a price before anyone is dispatched, and lets us refuse to dispatch on a price we no longer stand behind.

    Matching selects candidate drivers whose last location is inside a radius and whose status is available, ranks them by estimated time to pickup, and creates an offer with a 15-second expiry for the first candidate. If the driver rejects or the offer expires, the matcher offers the next candidate, up to a small maximum, then sets the trip to unfulfilled so the rider can retry. The offer is a row, not a push-only message. The driver app's accept calls the API. Push is a hint to open the app.

    Accept is the critical write: conditional update of the trip from matching to accepted with this driver and this offer id, and a conditional update of the driver from available to on_trip, in one database transaction. If either condition fails, the accept returns 409. The loser sees "offer no longer available." There is no second trip created for the winner. A unique partial index on the driver for active trip states makes a double assignment a constraint violation even if a bug skips a check.

    Cancel: rider may cancel before in_progress under the product rules, possibly with a fee the quote policy named. Driver cancel before pickup returns the trip to matching or to cancelled if policy says the rider should re-request. Ops can cancel. Every transition is an explicit allowed edge. Anything else is 409.

    Complete: driver marks complete, or ops does. The final fare is calculated, stored, and capture is requested. If capture fails, the trip is still completed and a billing exception is queued. The car has already moved. We do not un-complete a trip because the vendor timed out.

    Location: the driver app sends latitude, longitude, heading, and a client timestamp every 4 seconds while available or on a trip. The server stores the latest point for matching and appends a sampled trail for the active trip. The latest point is what matching reads. The trail is for safety review and dispute, with a retention limit.

    Quality scenarios

    Match latency. From confirm to first offer delivered to a driver app, p95 under 3 seconds in the metro at the stated peak, when at least one driver is eligible. If none are eligible, the rider sees unfulfilled within 15 seconds, not a spinner.

    Single assignment. At most one driver has an active assignment for a trip, and at most one active trip is assigned to a driver. The measure is a constraint, tested with concurrent accepts, not a probability.

    Location freshness. For a driver whose app is connected, the point used by matching is no older than 10 seconds p95 during the peak. A point older than 30 seconds is not eligible. Stale drivers disappear from the candidate set rather than receiving an offer at a venue they left.

    Quote integrity. The multiplier and the fee schedule id stored on the trip at confirm are the ones used at complete. A surge change mid-trip does not reprice the trip. The rider can retrieve the frozen quote from the trip resource.

    Availability of confirm and accept is 99.9 percent monthly for the metro API, excluding the driver's radio. We do not promise a car exists. We promise an honest state.

    Geospatial candidate lookup

    Matching needs "drivers near this pickup," not "all drivers ordered by distance" via a scan. Two practical tools:

    Geohash. Encode latitude and longitude into a string prefix. Nearby drivers share a prefix. Precision 6 is about 1.2 km by 0.6 km, a reasonable bucket for a city venue district. Query the cell and its neighbors, then filter with haversine distance. Edge effects are why neighbors matter: a driver just across a cell boundary is still close.

    Hexagonal indexes. H3, published as an open library, uses hex cells with a cleaner neighbor set. Resolution 8 is sub-kilometer in cities. The same pattern applies: index drivers by cell id, query the cell and a ring, then exact-filter.

    PostGIS. For one metro and the update rate below, geography points and a GiST index with ST_DWithin are enough and keep the candidate query and the trip transaction in one database. That is the v1 choice. Geohash or H3 in a separate in-memory index becomes attractive when location writes threaten the primary or when many cities share one matcher process. The record says: start with PostGIS in the trip database; reopen when location-write latency or CPU crosses a threshold we will measure, or when we split cities into partitions.

    The index is a derived view of the latest location. Assignment does not "lock a cell." Two matchers could still offer two drivers the same trip if we ran two matchers without the trip-row condition. The cell index finds candidates. The conditional update elects the winner. Confusing those two is how double dispatch survives a beautiful map.

    Quote, surge, and fare

    Surge, if we use it at all for a staff shuttle, is a multiplier per cell computed from demand and supply over a few minutes, published as a small table. At confirm, the trip copies policy_version, multiplier, quoted_amount, and estimate_meters. Complete computes the final amount with those frozen inputs plus actuals if the policy is metered, and caps the result with a rule written in the policy (for example, not more than a percentage above the quote unless the rider changed the drop-off). Changing the drop-off is a new quote and an explicit rider confirm, not a silent repricing.

    A quote service that the complete path calls live will reprice the trip under tonight's surge. That is a product bug even if it is a clean microservice. Freeze the inputs.

    State machine

    text
    requested (quote confirmed) -> matching -> accepted -> arriving -> in_progress -> completed
    \-> cancelled
    matching -> unfulfilled
    matching -> cancelled
    accepted -> cancelled
    arriving -> cancelled
    in_progress -> cancelled is ops-only and rare

    arriving means the driver marked en route or the server inferred proximity. Inference is optional and easy to get wrong in urban canyons. Prefer an explicit driver action for arriving and in_progress (start trip) so GPS noise does not start a fare. Complete is explicit. Automatic complete on geofence is a reopen, because a wrong geofence charges a rider who is still at the venue.

    The implementation is a table of allowed (from, to, actor) and an update WHERE id = ? AND status = ?. Zero rows updated means 409. This is easier to test than a workflow engine and does not hide the invariant.

    What the primer skips

    The primer's map and surge box do not say that location is a firehose, that assignment is a conditional write, or that a completed trip survives a failed capture. Dispatch is small in trip QPS and strict in invariants. Designing it like a high-QPS cache misses the bug that matters: two drivers, one rider.

    Architecture and Design Diagrams

    mermaid
    C4Context
    title Dispatch — system context
    Person(rider, "Rider", "Requests a trip and sees status")
    Person(driver, "Driver", "Sends location and accepts offers")
    Person(ops, "City ops", "Cancels and reviews exceptions")
    System(dispatch, "Dispatch", "Quotes, matches, and records trip state")
    System_Ext(pay, "Payments vendor", "Authorize and capture")
    System_Ext(maps, "Routing provider", "Optional distance estimate")
    Rel(rider, dispatch, "Quote, confirm, track", "HTTPS")
    Rel(driver, dispatch, "Location, accept, start, complete", "HTTPS")
    Rel(ops, dispatch, "Intervene", "HTTPS")
    Rel(dispatch, pay, "Authorize and capture", "HTTPS")
    Rel(dispatch, maps, "Estimate route", "HTTPS")
    mermaid
    flowchart LR
    rider[Rider app]
    driver[Driver app]
    edge[Load balancer]
    api[Dispatch API]
    match[Matcher]
    db[(Trips, drivers, locations)]
    pay[Payments vendor]
    maps[Routing provider]
    rider --> edge
    driver --> edge
    edge --> api
    api --> db
    api --> match
    match --> db
    api --> pay
    api --> maps

    The matcher can be a module in the same deployable while one city fits in one database. It is drawn separately because its loop is asynchronous: confirm returns when the trip is matching, and the offer arrives as a push or a poll. The rider does not hold an HTTP request open for the whole search.

    mermaid
    sequenceDiagram
    participant R as Rider
    participant A as Dispatch API
    participant M as Matcher
    participant D as Driver app
    participant DB as Database
    R->>A: confirm quote, idempotency key
    A->>DB: insert trip matching
    A-->>R: trip id
    M->>DB: candidates near pickup, fresh locations
    M->>DB: insert offer
    M-->>D: push hint
    D->>A: accept offer
    A->>DB: conditional trip and driver update
    alt won
    A-->>D: 200 accepted
    A-->>R: status accepted
    else lost
    A-->>D: 409 gone
    end
    mermaid
    stateDiagram-v2
    [*] --> matching
    matching --> accepted
    matching --> unfulfilled
    matching --> cancelled
    accepted --> arriving
    accepted --> cancelled
    arriving --> in_progress
    arriving --> cancelled
    in_progress --> completed
    in_progress --> cancelled

    Technical Explanation

    Location path

    Each update is a small JSON body. The API authenticates the driver, rejects points that fail basic bounds, and upserts driver_location keyed by driver id. An append of the trail happens only for samples: every update while on a trip is more than we need for disputes; storing every 4 seconds for a 30-minute trip is 450 points, which is fine, so v1 can append all on-trip points and only the latest point while available. Available-driver history at 4-second resolution for every idle hour is the expensive choice. Keep latest-only when available, full trail when on_trip. Retention of trails is 30 days, then delete. Safety and disputes have a month to open. A forever trail is a surveillance product we did not agree to build.

    Updates from the future or from far jumps: drop a point that implies a speed beyond a threshold, and keep the previous point. GPS glitches otherwise teleport a driver across the city and into a candidate set. The threshold is a policy constant, written down, not a hidden filter.

    The driver app buffers a few points when the radio drops and sends them with client timestamps. The server applies only the newest reasonable point for the latest-location row, and may store the trail in order. Matching reads latest, not the buffer. A driver who has been silent for 30 seconds is ineligible. Their app, when it returns, is eligible again after a fresh point. Do not backfill eligibility from a buffered point that is already old.

    Match loop

    A worker polls or is notified of trips in matching without a live offer. Notification can be a LISTEN/NOTIFY channel or a queue message emitted after commit. The worker:

    1. Loads the pickup.
    2. Queries candidates: status available, location newer than 30 seconds, within radius, not the rider themselves if staff can also drive.
    3. Sorts by estimated time. v1 estimate is distance over an assumed city speed, not a live traffic call for every candidate. A routing port for the top three is optional and has a timeout. If it times out, use the distance sort. Matching must not block on a maps vendor.
    4. Inserts an offer row expiring in 15 seconds, conditional on the trip still being matching and having no unexpired offer.
    5. Pushes a hint to the driver. The driver app also polls GET /v1/drivers/me/offers so a missed push still works. Push is best effort, the same lesson as Cohort Wire.

    When the offer expires, a sweeper or the same loop marks it expired if still open and tries the next driver, skipping anyone already offered for this trip. After N attempts or a total budget of about 45 seconds, the trip becomes unfulfilled. The rider client displays that state and a retry button that confirms a new quote if the old one expired.

    Two workers must not offer two drivers at once for the same trip. The conditional insert "no unexpired offer" is the mutex. A unique partial index on offer(trip_id) WHERE state = 'open' makes the race visible. Lesson 36: the constraint is the lock.

    Accept path

    text
    BEGIN
    UPDATE trip SET status = 'accepted', driver_id = :d, accepted_at = now()
    WHERE id = :t AND status = 'matching'
    UPDATE offer SET state = 'accepted'
    WHERE id = :o AND trip_id = :t AND driver_id = :d AND state = 'open' AND expires_at > now()
    UPDATE driver SET duty = 'on_trip'
    WHERE id = :d AND duty = 'available'
    COMMIT if all three updated one row, else ROLLBACK

    If the driver was just taken by another trip, the third update fails and the whole accept fails. The trip remains matching for someone else. If two devices of the same driver accept two trips, the duty update saves us. The partial unique index UNIQUE (driver_id) WHERE status IN ('accepted','arriving','in_progress') on trip is the backstop.

    Authorize payment after the transaction commits, or before? If we authorize before and then lose the accept race, we must void. If we authorize after and the vendor is down, we have a driver assigned and no hold on funds. For a staff shuttle billed to a company account, the risk differs from a consumer card. v1 for consumer-like personal cards: commit the assignment, then authorize. If authorize fails, set a payment_attention flag and allow ops to cancel within a grace period before pickup. Do not leave the rider believing they have a car when we know the card was declined. So: commit assignment, call vendor, on decline transition to cancelled with reason payment_declined and free the driver in the same style of conditional update. The driver sees a short-lived assignment. That is better than a rider in a car with a known bad card, and better than holding the driver during a slow vendor call before we know we won the race. The vendor call is outside the trip transaction. Lesson 32: do not hold database locks across a network call to a payment vendor.

    Fare and complete

    Complete computes money from frozen policy and writes final_amount in the same update that sets completed, conditional on in_progress. Then it enqueues capture with the trip id as idempotency key at the vendor. Capture retries are safe. A decline after the ride creates a billing exception, not a new trip state called "uncompleted." Ops have a queue. The rider's receipt shows the amount and "payment pending" if needed.

    Cancellation fees, when policy charges them, are a similar small capture with their own idempotency key tripId-cancel. A rider who retries the cancel button must not be charged twice. The transition to cancelled happens once; the fee command uses the trip id.

    Scale, caching, load balancing

    API nodes are stateless behind a load balancer. Driver polls and rider status polls are the chatty reads. Cache the rider's trip status for one second if you must, not for the match decision. Do not cache candidate sets across requests: a 5-second-old candidate list assigns a driver who left. The latest-location table is the cache, and its freshness rule is the 30-second eligibility cutoff.

    At the numbers below, one primary in the city handles trip writes easily. Location upserts are the load. If they hurt, split latest locations into a key-value store keyed by driver id, and keep trips in the relational database. The matcher reads locations from that store and writes assignment only to the relational database. That split is the first scale tactic. Sharding trips by city id is the tactic when a second and third metro share a product and you want failure isolation. Do not shard one city by geohash for trip rows. A trip's pickup cell and drop-off cell differ, and the transaction wants one trip row.

    Hot cells: an airport queue where fifty drivers sit in one geohash. The candidate query returns many rows, then the ranker picks one. The bug is offering all of them or always offering the same first row. Rank with a rotation, or by time since last trip, and cap the candidate list you lock or notify. One open offer at a time, already required, prevents a stampede of accepts. The airport is a product rule as much as an index problem: a virtual queue ordered by arrival in the queue is fairer than pure distance when everyone is in the same lot. v1 can special-case a known airport polygon with a queue. That rule belongs in the matcher policy table so it is not an undocumented branch.

    Security and privacy

    Riders see their trip and the first name and vehicle description of the assigned driver, plus a live location while the trip is active. They do not get a historical trail API. Drivers see pickup and drop-off for offers they received, and the rider's display name. They do not get the rider's other trips. Ops see more, under audit.

    Location is sensitive. Access to trails is a privileged action with a reason code. Logs of coordinates are retention-limited. Do not put raw trails in the analytics warehouse at full resolution; aggregate demand by cell and hour for surge, which is enough.

    Authentication is the company IdP for staff riders and a driver credential for the driver app. The driver app's refresh token is revocable. A lost phone is a duty flip to offline and a token revoke, which stops location injection. Rate-limit location updates to a little faster than the 4-second cadence so a broken client cannot write thousands per second. Lesson 35.

    A rider must not set the driver id on create. Assignment is server-side. An API that accepts driverId from the rider is a fraud tool.

    Failure handling

    DependencyNoticeUser seesPersistedRefusal
    Primary downTimeoutsConfirm and accept fail; clients retry idempotentlyLast committed stateAssigning in the app's memory
    Maps timeoutBudget exceededQuote uses rough distance and says so, or quote fails 503 if policy forbids a rough quoteNothingBlocking match on maps
    Push provider downSend errorDriver poll still shows the offerOffer rowWaiting for push to commit the offer
    Driver radio dies after acceptLocation ageRider sees last point and a stale flag; ops alert if before pickupTrip stays acceptedAuto-cancel in the first minute without a policy
    Payment decline after acceptVendor responseTrip cancels, driver freed, rider toldCancel reasonLeaving the rider in accepted with a known decline
    Capture failure after completeVendor errorReceipt shows pending; ops queueTrip completedReversing completed
    Matcher lagOffer age metricRider waits; then unfulfilled if budget exceededTrip rowInfinite matching

    Clock skew: offer expiry uses the database clock, not the driver phone clock. A phone set five minutes fast must not expire offers early by sending its timestamp as authority.

    Trade-offs

    PostGIS in the trip database versus a separate geospatial service. One system is easier and couples location write load to trip commits. We start there for one metro and split locations out when measurements say so.

    Explicit trip start versus geofence. Explicit is harder for drivers to forget and harder for GPS to get wrong. We take explicit.

    Authorize after assignment versus before. After assignment, we may have to cancel on decline. Before assignment, we hold funds for riders who lose the race or wait on a slow vendor while holding a driver. We assign, then authorize, then cancel on decline, with the vendor call outside the lock.

    Queue at the airport versus pure distance. Fairness for drivers in a lot. A polygon rule is local configuration.

    One city database versus global active-active. A trip does not span cities in v1. Partition by city when we expand. Avoid a worldwide lock.

    Practical Implementation or Design Walkthrough

    Assumptions

    IdAssumption
    R1One metro
    R220,000 trips per day
    R3Peak hour holds 20 percent of the day's trips
    R41,500 drivers online at peak
    R5Location update every 4 seconds while on duty
    R6Average trip 20 minutes on trip, drivers on duty 4 hours, half the time available
    R7Quote-to-confirm ratio 2:1, so quotes are twice confirms
    R8Trail retained 30 days for on-trip points only
    R9Offer attempts up to 3, offer TTL 15 seconds

    Arithmetic

    Peak trip confirms. Daily average:

    [ \frac{20{,}000}{86{,}400} \approx 0.23 \text{ trips/s} ]

    Peak hour is 20 percent of the day in 3,600 seconds:

    [ \frac{0.2 \times 20{,}000}{3{,}600} \approx 1.1 \text{ confirms/s} ]

    Even a ugly spike of ten times that is about 11 confirms per second. The trip primary is bored if location updates are excluded. Matching work is a few candidate queries per second. The hard part is correctness under concurrency of two accepts, which can happen even at 1 trip per second when offers overlap. Do not skip the conditional update because QPS is low. Low QPS is when people skip constraints and get paged anyway.

    Location writes:

    [ \frac{1{,}500}{4} = 375 \text{ upserts/s} ]

    at peak. Each is a small row update. This is the first real database load. It is still within one well-indexed primary if the upsert is a single row by primary key and not a transaction that also writes a trail and updates trip state for every idle driver. Trail writes only on trip. If half of drivers are on trip at peak, trail inserts are (750 / 4 \approx 190) per second. Combined, a few hundred writes per second of small rows is a sizing exercise for one primary, not a shard. Measure p95 of the upsert. The reopen number in the record can be: split the latest-location store when upsert p95 exceeds 20 ms for five minutes or CPU is saturated.

    Storage of trails, 30 days. Assume 8,000 on-trip minutes per day if 20,000 trips times 20 minutes is 400,000 minutes, times one point per 4 seconds:

    [ 400{,}000 \times \frac{60}{4} = 6 \times 10^6 \text{ points/day} ]

    Times 30 days is (1.8 \times 10^8) points. At 64 bytes, about 12 GB. Easy. Idle-driver full history would multiply this by the idle hours and is why we do not store it.

    Read path: each active rider polls status every 2 seconds. If 200 trips are in flight at peak (20-minute trips at 1.1 per second is about (1.1 \times 1{,}200 = 1{,}320) seconds of trips... wait, concurrency of trips is arrival rate times duration. 1.1 trips per second times 20 minutes is (1.1 \times 1{,}200 = 1{,}320) concurrent trips). Polling every 2 seconds is 660 reads per second, plus driver polls. Still comfortable. Push status changes to cut polls later. v1 can poll. Do not open a WebSocket design until polls show up in the budget. Lesson 50 already taught sockets; this product does not need them on day one.

    Multiplier for twenty cities, labeled M4: if each city resembles this one, partition by city and keep the same schema. A national hotspot event in one city does not require a global geospatial index. The matcher for a city runs against that city's location table so an airport surge in one metro cannot lock another city's rows.

    API

    POST /v1/quotes

    • Body: pickup lat/lng, drop-off lat/lng, product type.
    • 201 quote id, amount, multiplier, policy version, expiresAt, estimateQuality of routed or rough.
    • 422 coordinates outside the service polygon.
    • 503 if a routed quote is required and the maps port timed out. If policy allows rough quotes, return 201 with rough.

    POST /v1/trips

    • Body: quoteId, Idempotency-Key.
    • 201 trip id, status matching. Replay returns the same trip.
    • 409 quote expired or already consumed by a different key.
    • 422 quote's rider does not match the caller.

    POST /v1/offers/{offerId}/accept

    • Driver auth. 200 trip accepted, or 409 if the offer expired or the conditional update lost.
    • The response body on 409 is offer_gone. The client returns to available and polls.

    POST /v1/trips/{id}/cancel, POST /v1/trips/{id}/start, POST /v1/trips/{id}/complete

    • Actor-specific. 409 on illegal transition. Complete returns the final amount.

    POST /v1/drivers/me/locations

    • Body: lat, lng, heading, client time. 204. 429 if faster than the allowed cadence. 401 if revoked.

    GET /v1/trips/{id}

    • Rider or assigned driver or ops. Includes status, frozen quote, and driver public info when assigned. Location of the driver only while the trip is active, and only a recent point, not the trail.

    GET /v1/drivers/me/offers

    • Poll path for the current open offer, if any.

    Schema

    sql
    CREATE TABLE driver (
    id uuid PRIMARY KEY,
    duty varchar(16) NOT NULL,
    CONSTRAINT driver_duty_chk CHECK (duty IN ('offline', 'available', 'on_trip'))
    );
    CREATE TABLE driver_location (
    driver_id uuid PRIMARY KEY REFERENCES driver(id),
    lat double precision NOT NULL,
    lng double precision NOT NULL,
    geog geography(Point, 4326) NOT NULL,
    recorded_at timestamptz NOT NULL
    );
    CREATE INDEX driver_location_gix ON driver_location USING GIST (geog);
    CREATE TABLE fare_quote (
    id uuid PRIMARY KEY,
    rider_id uuid NOT NULL,
    policy_version integer NOT NULL,
    multiplier numeric(6,3) NOT NULL,
    quoted_amount integer NOT NULL,
    estimate_m integer NOT NULL,
    quality varchar(16) NOT NULL,
    expires_at timestamptz NOT NULL,
    consumed_trip uuid
    );
    CREATE TABLE trip (
    id uuid PRIMARY KEY,
    rider_id uuid NOT NULL,
    driver_id uuid,
    quote_id uuid NOT NULL REFERENCES fare_quote(id),
    status varchar(16) NOT NULL,
    pickup_lat double precision NOT NULL,
    pickup_lng double precision NOT NULL,
    drop_lat double precision NOT NULL,
    drop_lng double precision NOT NULL,
    final_amount integer,
    idempotency_key varchar(80) NOT NULL,
    created_at timestamptz NOT NULL,
    UNIQUE (rider_id, idempotency_key)
    );
    CREATE UNIQUE INDEX one_active_trip_per_driver
    ON trip (driver_id)
    WHERE status IN ('accepted', 'arriving', 'in_progress');
    CREATE TABLE offer (
    id uuid PRIMARY KEY,
    trip_id uuid NOT NULL REFERENCES trip(id),
    driver_id uuid NOT NULL,
    state varchar(16) NOT NULL,
    expires_at timestamptz NOT NULL
    );
    CREATE UNIQUE INDEX one_open_offer_per_trip
    ON offer (trip_id)
    WHERE state = 'open';

    Money is integer minor units. Multipliers are numeric, not binary floats. The geography column is derived from lat/lng in the same write so the index cannot drift from the point you store.

    Spring notes

    Accept is one @Transactional method that runs the three updates and throws if any count is not 1, which rolls back. The payment port is called in a listener after commit, or by an outbox worker, never inside that transaction. Lesson 33's outbox fits the capture command and the authorize command.

    java
    @Transactional
    public AcceptResult accept(UUID offerId, UUID driverId) {
    Offer offer = offers.lockOpen(offerId, driverId);
    if (offer == null || offer.expired()) {
    throw new OfferGoneException();
    }
    int tripRows = trips.markAccepted(offer.tripId(), driverId);
    int dutyRows = drivers.markOnTrip(driverId);
    int offerRows = offers.markAccepted(offerId);
    if (tripRows != 1 || dutyRows != 1 || offerRows != 1) {
    throw new OfferGoneException();
    }
    return AcceptResult.ok(offer.tripId());
    }

    OfferGoneException must mark the transaction rollback-only. A Spring UnexpectedRollbackException leaking as 500 is a bug: map it to 409. Concurrent callers should see conflict, not an internal error, or their apps will retry blindly and confuse drivers.

    The location handler is a separate, short transaction so a slow match query cannot queue behind it on the same connection pool. Use a bulkhead, lesson 41, so location upserts keep a pool of their own if match queries get heavy. At 375 writes per second this matters more than trip confirm.

    Quote calculation is a pure function of policy version, multiplier table, and distance. Test it without the database. Persist the inputs, not only the output.

    Tests

    • Two drivers accept one offer and one related race where one driver accepts two trips: exactly one success, the other 409, one row in the partial unique index.
    • Expired offer cannot accept even if the phone clock disagrees.
    • Idempotent trip create returns one trip.
    • Expired quote cannot confirm.
    • Surge change after confirm does not change quoted_amount on the trip.
    • Maps timeout produces a rough quote only when policy allows.
    • Payment decline after accept frees the driver and cancels.
    • Capture failure leaves completed and one billing exception, and a retried capture does not double-charge in the vendor's idempotency sense: assert the same idempotency key is sent.
    • Location older than 30 seconds does not appear in candidates.
    • A teleport point is dropped.

    Records

    ADR-053-1. Assignment is a conditional update in one database, with a partial unique index of one active trip per driver. Rejected: a distributed lock manager; trusting the client; last-write-wins on driver_id. Reopen a separate lock system only if we split trip state across stores, which we should not do casually.

    ADR-053-2. v1 geospatial lookup is PostGIS in the city database. Latest location only while available. Fare inputs freeze at confirm. Rejected: encode-on-read of every driver on each match; live surge at complete; storing idle trails indefinitely.

    Enterprise Real-World Scenarios

    The airport release

    A workshop ends and forty riders request trips within three minutes while thirty drivers wait in the hotel lot, one cell over from the venue. Confirms are about 40 in 180 seconds, still under one per second average, with a burst. The candidate query returns the whole lot. If the matcher notifies every driver, the accept race is loud and drivers tap a 409. The open-offer constraint serializes this: one offer, fifteen seconds, next driver. The rider waits through a couple of timeouts if drivers are slow to tap, then gets someone. Ops wants a queue instead of "whoever is closest," because closest is noise in a lot. The airport polygon policy orders drivers by time they entered the polygon, stored as a queue position updated from location updates when they cross the boundary. Distance ranking applies outside the polygon. The incident when this is missing is a social one: the same three drivers get every offer because their GPS sits slightly closer to the door. The fix is the queue rule, not a faster database.

    Payment decline in this burst must free the driver quickly or the lot loses capacity to ghost assignments. The after-commit authorize path needs a timeout shorter than the rider's patience, and the cancel-on-decline transition has to work while the next offer logic is running. Test that combination. It is the real airport bug: a declined card stuck in accepted, driver duty stuck in on_trip, and the matcher skipping them as unavailable.

    The driver who was offered and entered a tunnel

    The offer is delivered, the driver accepts, the radio dies, and the location age exceeds 30 seconds before pickup. The rider sees a car that does not move, with a stale flag once the point is old. Auto-cancel would free the rider to request someone else and might also strand a driver who is simply in a tunnel and about to arrive. v1 does not auto-cancel. The rider may cancel under policy. Ops gets an alert when an accepted trip has no fresh location for two minutes before pickup. The alert is a metric on location age joined to status, not a human staring at a map all day. When the driver emerges, later points flow again and the stale flag clears. If they never arrive, ops cancels and the matcher can run again only if the rider confirms a new quote. Reusing the old trip silently is how a cancelled mental model and a live row diverge. A new confirm is a new trip id. The old one stays cancelled for the dispute trail.

    Best Practices

    • Treat assignment as a conditional write with a unique active-trip constraint. Test the race.
    • Keep quotes and trips as different resources. Freeze price inputs on the trip.
    • Make location eligibility depend on age. Drop impossible jumps.
    • Do not hold a database transaction open across the payment vendor.
    • Complete the trip even if capture fails, and make capture idempotent.
    • Use the database clock for offer expiry.
    • Store on-trip trails for a limited retention. Do not keep idle-driver history by default.
    • Partition by city when you expand. Keep matching local to the city's locations.
    • Give drivers a poll path so push is not required for correctness.
    • Special-case known queues such as an airport lot in policy data.

    Common Mistakes and Anti-Patterns

    Last write wins on driver_id. Two accepts both return success. The partial index and the conditional update exist to make that impossible.

    Locking a driver in an external lock service and then writing the trip. The lock can expire or the process can die between the two, and you still need the database constraint. Start with the constraint.

    Repricing at complete from the live surge table. The rider's quote was a promise. Freeze the policy version and multiplier.

    Matching by scanning every driver. It will appear to work in a demo with twenty rows. The index and the radius are the design.

    Auto-starting the fare from a geofence. Urban GPS will start and stop fares in the parking garage. Use an explicit start.

    Storing every idle location forever. The volume is a surveillance archive without a scenario. Latest point plus on-trip trail is the product.

    Calling the maps vendor inside the accept transaction. Slow routing then holds row locks and offers time out falsely.

    Trusting the client timestamp for expiry. Clock skew becomes a way to accept a dead offer or to reject a live one.

    A nationwide single table of locations with one hot airport cell locking the matcher. Partition by city, and use a queue policy inside the hot polygon.

    Troubleshooting and Design Review

    Symptom: Two drivers are en route to the same rider.

    Likely cause: accept path updated the trip without the driver duty condition, the unique index is missing, or two trip ids were created for one request because idempotency failed.

    Investigation: both trip rows, both driver duty values, idempotency keys, whether the unique index exists in production.

    Corrective action: cancel the extra assignment, restore the conditional update, add the index, add the concurrency test. Do not "fix" it by telling drivers to call each other.

    Symptom: Drivers in the lot never get offers; a driver two blocks away gets them all.

    Likely cause: rank is pure distance and the same GPS point always wins, or the location age filter marks lot drivers stale because the app sleeps in the queue.

    Investigation: candidate query results, recorded_at, offer history.

    Corrective action: queue policy in the polygon, and a driver-app heartbeat that keeps the point fresh while waiting. Do not shorten the staleness window so far that idle phones with real drivers vanish.

    Symptom: Riders are charged tonight's surge for a trip quoted at 1.0.

    Likely cause: complete recalculated multiplier instead of reading the trip's frozen value.

    Investigation: quote row versus fare line versus current cell multiplier.

    Corrective action: compute from frozen inputs. Refund the difference. Add a test that changes the multiplier table between confirm and complete.

    Symptom: Offers expire immediately for some drivers.

    Likely cause: expiry compared to the phone clock, or sweeper using a different timezone, or offer inserted with an expiry in the past because of a null clock.

    Investigation: expires_at versus database now() at insert, client logs.

    Corrective action: compare and set expiry only in SQL with now() + interval '15 seconds'.

    Symptom: Location table CPU pegs while trip QPS is tiny.

    Likely cause: trail insert on every idle update, missing primary-key upsert, or a GiST index rebuild on a bloated table.

    Investigation: statement rates, idle versus on-trip writes.

    Corrective action: latest-only when available, split the location store if p95 stays high. This is the scale path in the record, not a surprise.

    Design-review questions: Which write makes assignment exclusive? What happens to the loser of an accept race? What is frozen at confirm? How old may a location be and still match? What if capture fails after the rider gets out? What if push never arrives? When do you leave PostGIS? Where may location history be read, and for how long is it kept?

    Interview Questions and Answers

    Q1. How do you prevent one driver from being assigned two trips, or one trip from getting two drivers?

    Accept runs in one transaction: the trip must still be matching, the offer must be open and unexpired, and the driver must still be available. All three updates must change one row or the transaction rolls back. A partial unique index on active trip status per driver is the backstop. The losing caller receives 409. I do not use last-write-wins, and I do not depend on an external lock that can succeed when the database write does not.

    Q2. What is the real scalability question if trip creates are only a few per second?

    Location updates. Fifteen hundred drivers every four seconds is a few hundred upserts per second, and an on-trip trail adds more. Trip rows are quiet. I size and index the location write path, keep idle history as a latest point only, and split locations into their own store when latency says so. Sharding trips across a city does not fix a hot location table.

    Q3. How do you find nearby drivers?

    v1 uses a geography point, a GiST index, and a radius query, then filters by location age and duty. Geohash or H3 is the same idea of cells plus a neighbor ring plus an exact distance filter, and I would move to it if we lift locations out of the relational database. The index only proposes candidates. The conditional accept elects the driver. I do not lock a map cell and call that assignment.

    Q4. How do quotes and surge work?

    A quote is a short-lived resource with a policy version, multiplier, and amount. Confirming it creates a trip that copies those inputs. Complete uses the copy. A later surge change does not reprice the trip. If the rider changes the drop-off, that is a new quote they must accept. I label rough distance estimates when the routing port times out, or I fail the quote if the product forbids a rough price.

    Q5. What happens when the driver accepts and the card is declined?

    The assignment commits first, without holding the database lock during the vendor call. Authorize runs after commit. On decline, a second conditional transition cancels the trip and frees the driver, and the rider is told. I do not leave the trip accepted with a known bad payment. I also do not authorize before I know this driver won, or I will void a lot of lost races and slow the winner behind the vendor.

    Q6. The ride finished and capture failed. Now what?

    The trip stays completed with a final amount. Capture retries with an idempotency key derived from the trip id. Ops see a billing exception. I will not delete the trip or return the rider to in_progress. The physical world already happened. Lesson 32's saga thinking applies at the payment boundary, and compensation is a retry or a collections process, not a time machine.

    Q7. How should the system behave when push notifications fail?

    The offer row is already durable. The driver app polls an offers endpoint. Matching does not wait for the push provider before considering the offer open, and it does expire the offer on the database clock if the driver never responds. Push is a latency optimization, as it was for chat in lesson 50.

    Q8. What do you do about GPS that jumps across the city?

    Drop points that imply an impossible speed and keep the previous point. Exclude points older than about 30 seconds from matching. Do not start or complete trips from geofences in v1. Explicit driver actions move arriving, in_progress, and completed. A tunnel that pauses updates raises a stale flag and an ops alert; it does not auto-cancel in the first minute.

    Q9. How would you add a second city?

    Partition by city: separate location and trip data so matching in one metro cannot lock or scan another. Quotes and trips stay in their city. A rider's request is rejected if the pickup is outside a service polygon. I would not build a single global sorted set of drivers. The scale path inside one city, if location writes hurt, is a dedicated latest-location store, still queried by that city's matcher.

    Q10. Where does idempotency matter?

    The rider's confirm uses an idempotency key so a double tap or a timeout retry creates one trip. Cancel fees and payment capture use the trip id as the idempotency key at the vendor. Driver location posts are naturally last-writer for the latest point; they do not need a unique event id unless we bill per update, which we do not. Accept is not idempotent in the sense of "run it twice and assign twice"; a replay after success should return the same success if the trip is already accepted by this driver, and 409 if it was accepted by someone else. Handle that replay explicitly so a retry after a lost 200 does not look like a failure to the driver who won.

    Q11. What are you unwilling to cache?

    The candidate set and the driver's duty. A few seconds of cache would offer a driver who already accepted someone else or who left the radius. Trip status for the rider can be pushed or polled from the primary at this QPS. A CDN does not belong in front of location or accept. This is the opposite shape from the video lessons, where the bytes were the cache and the control plane was small.

    Q12. How do you test the race without fifty phones?

    A test starts two transactions that accept related offers and asserts one commit and one 409, then reads the unique index. A second test expires an offer using the database clock. A third changes the surge table between quote and complete and asserts the frozen amount. A fourth feeds a teleported coordinate and asserts the stored point did not move. I do not call the suite green because a single-threaded demo assigned a driver.

    Hands-On Exercises

    Exercise: Offer expiry and a retrying driver

    Problem statement. Drivers tap accept just as the offer expires, often twice because the UI retries. Some of those taps belong to the driver who should win if the server received the first tap in time. Some belong to a driver whose offer is already dead and whose retry must not steal a trip that moved on. Specify the timing, the status codes, and the database conditions.

    Tasks.

    1. Define the offer window and which clock measures it.
    2. Specify the response for: in-time accept, duplicate accept from the winner, late accept, and accept after another driver already won a newer offer.
    3. Show the conditional updates and the unique indexes you rely on.
    4. State what the driver app displays in each case, and what it does with a timeout where the outcome is unknown.
    5. Estimate, with labeled assumptions, how many offers per peak minute your city issues if each trip tries up to three drivers. This is a workload for the offer table, not a reason to shard.
    6. Note the failure if the push arrives after expiry. The poll path must agree with accept.
    7. Write one ADR rejecting "extend the offer whenever a tap is in flight on the client."
    8. List the tests that would fail if someone compared expiry to the phone clock.

    Expected deliverables. Status table, transition notes, arithmetic, ADR, tests.

    Evaluation rubric.

    Assessment areaWeightWhat "good" looks like
    Requirements and assumptions15%Late versus duplicate versus lost-response are different
    Architecture and design quality25%One transaction and one clock are named
    Technical correctness20%Unique indexes match the states you claim
    Scalability, reliability, and security20%Retry cannot assign a second driver
    Trade-off reasoning10%The rejected client-side extension is explicit
    Documentation and communication10%A mobile engineer could implement the UI from the packet

    A design that returns 200 to every accept and reconciles later is not ready.

    Lesson Summary

    Dispatch is a trip state machine guarded by conditional updates, with a geospatial index that only proposes candidates. Quotes freeze the money inputs. Location updates dominate the write rate and are kept fresh or excluded. Offers expire on the database clock and can be polled if push fails. Payment authorization happens after assignment and outside the lock; a decline cancels; a failed capture after completion does not rewrite history.

    At twenty thousand trips a day in one city, trip QPS is about one per second at the peak hour and location upserts are a few hundred per second. One database with PostGIS is a coherent v1. The next moves are a split location store and a per-city partition, not a global lock on all drivers. The airport queue, the tunnel, and the double accept are the scenarios that justify the constraints the low QPS might tempt you to skip.

    Lesson 54 leaves moving vehicles and designs a catalog, a cart, and an order pipeline where the scarce resource is a seat or a unit of stock, and the consistency question is a saga across inventory and payment rather than a single trip row.

    Further Reading

    Ready to mark this lesson complete?Track your journey across the entire course.