Guide

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:

TypeScript
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 -> completed

A 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 completed skips 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

  1. Why does the transition table refuse accepted → completed?

    Answer: a trip must pass through arriving and in_progress first. Skipping them would skip the pickup and the fare logic.

  2. What stops a retried charge from billing the rider twice?

    Answer: the idempotency key trip:{id}:charge is 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.