Case studies
Trip state machine
How a trip moves through explicit states from request to payment, why only legal moves are allowed, and how a payment on completion stays idempotent.
A trip is a long-running process. It starts with a request, passes through matching, a pickup and a drop-off, and ends with a charge. Each step must follow the one before it. A state machine names every state and lists the legal moves between them. Anything else is refused.
The big idea
Model a trip as a set of named states with allowed transitions:
searching → offered → accepted → arriving → in_progress → completed
↑ ↘ rejected or timeout
└──────────┘
any active state → cancelled (with rules)When a driver rejects an offer or times out, the trip goes back to searching. A cancellation is allowed from any active state, but the rules, such as fees, depend on the state.
Store the trip's state in a durable database, separate from the location index. The index can be lost and rebuilt. The trip record cannot.
Walking through it
A small transition table enforces the rules. Each move checks the table first:
const allowed: Record<string, string[]> = {
searching: ["offered", "cancelled"],
offered: ["accepted", "searching"], // reject or timeout goes back to searching
accepted: ["arriving", "cancelled"],
arriving: ["in_progress", "cancelled"],
in_progress: ["completed"],
completed: [],
cancelled: [],
}
function move(from: string, to: string): string {
if (!allowed[from].includes(to)) throw new Error(`illegal ${from} -> ${to}`)
return to
}
let state = "searching"
for (const next of ["offered", "accepted", "completed"]) {
try {
state = move(state, next)
console.log("now", state)
} catch (e) {
console.log((e as Error).message)
}
}Output:
now offered
now accepted
illegal accepted -> completedA trip cannot skip arriving and in_progress, so the jump to completed is refused. In a real system, the table is also enforced in the database, so a buggy worker cannot write an illegal state.
Charging on completion. When a trip completes, it emits an event to the payments workflow. The charge uses an idempotency key built from the trip, such as trip:{id}:charge. A retry of the same charge reuses that key, so it cannot create a second charge. Tips and adjustments are separate postings against the same trip ID. If a charge fails, retry it with backoff. Never leave the trip in limbo without a view for operations. Payment details are covered in payment intents and states.
Common mistakes
- Deriving trip state from location pings. Location is noisy. The trip record is the source of truth.
- Allowing any transition. A bug that jumps to
completedskips the charge logic. - Creating a new trip on a payment retry. Retry the charge with the same key instead.
- Leaving no state for "no drivers found". Give failed searches a terminal state, so they are visible to operations.
Check yourself
-
Why does the transition table refuse
accepted → completed?Answer: a trip must pass through
arrivingandin_progressfirst. Skipping them would skip the pickup and the fare logic. -
What stops a retried charge from billing the rider twice?
Answer: the idempotency key
trip:{id}:chargeis the same on each retry, so the payment provider returns the first result.
Key takeaways
- A trip is a state machine with explicit states and legal transitions.
- Enforce the transitions in code and in the database.
- Store trip state durably, separately from the location index.
- Charge with an idempotency key tied to the trip, so retries are safe.
Further learning
Next, see how the system splits its data by city, in geo sharding and hot spots.
Related
Case study: ride-sharing
Uber-style dispatch: streaming driver locations into a geo index, matching under contention, trip state and geographic sharding. Start here for the ride-sharing map.
Payment intents and states
How a PSP payment intent moves through its states, why every confirm can end in an unknown outcome, and why your own status column should be the one you trust.
Idempotency and retries
Making operations safe to retry, with idempotency keys, deduplicating consumers and jittered backoff, so at-least-once delivery never means a double charge.
Distributed transactions
Keeping data consistent when one business operation spans several databases or services, using two-phase commit, sagas, the transactional outbox and reservations, and redrawing boundaries first.