Loading learning content...
A rider taps 'Request UberX.' What happens next involves dozens of services, multiple database transactions, real-time communication across devices, payment authorization, and continuous state management—all while the rider simply sees a car approaching on a map.
The hidden complexity:
A single 15-minute trip generates:
The reliability requirement:
Trip management must be exactly-once and durable. You cannot lose a trip in progress. You cannot charge a rider twice. You cannot strand a driver without payment. The trip state machine is the source of truth that coordinates all other systems.
By the end of this page, you will understand how to design a robust trip management system. You'll learn state machine design for complex workflows, event sourcing for auditability, payment orchestration with idempotency, and the failure handling patterns that ensure trips complete reliably even when things go wrong.
Every trip follows a defined lifecycle modeled as a finite state machine (FSM). Understanding this state machine is fundamental to trip management design.
Each state has specific properties that must be maintained:
| State | Driver State | Rider Can Cancel? | Billing Active? | Notifications |
|---|---|---|---|---|
| REQUESTED | — | Yes (free) | No | — |
| MATCHING | — | Yes (free) | No | Searching animation |
| MATCHED | Offered trip | Yes (possible fee) | No | Driver info shown |
| DRIVER_EN_ROUTE | Navigating to pickup | Yes (fee likely) | No | ETA updates |
| DRIVER_ARRIVED | At pickup | Yes (fee likely) | No | Driver arrived push |
| TRIP_STARTED | On trip | No | Yes (meter running) | Trip tracking active |
| TRIP_COMPLETED | Trip ended | No | Calculating | Fare shown |
| COMPLETED | Available again | — | Charged | Receipt sent |
1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374
class TripStateMachine: """ Implements trip state transitions with validation and side effects. """ VALID_TRANSITIONS = { REQUESTED: [MATCHING], MATCHING: [MATCHED, NO_DRIVERS], MATCHED: [DRIVER_EN_ROUTE, DRIVER_CANCELED, RIDER_CANCELED], DRIVER_EN_ROUTE: [DRIVER_ARRIVED, DRIVER_CANCELED, RIDER_CANCELED], DRIVER_ARRIVED: [TRIP_STARTED, NO_SHOW], TRIP_STARTED: [TRIP_COMPLETED, TRIP_INTERRUPTED], TRIP_COMPLETED: [PAYMENT_PROCESSING], PAYMENT_PROCESSING: [COMPLETED, PAYMENT_FAILED], PAYMENT_FAILED: [COMPLETED], // After retry or write-off } async function transition(trip, targetState, context): currentState = trip.state // Validate transition is allowed if targetState not in VALID_TRANSITIONS[currentState]: raise InvalidTransitionError(currentState, targetState) // State-specific validation await validateTransition(trip, targetState, context) // Begin transaction async with database.transaction(): // Update trip state trip.state = targetState trip.stateUpdatedAt = now() trip.stateHistory.append({ fromState: currentState, toState: targetState, timestamp: now(), context: context }) await database.save(trip) // Emit event for downstream consumers await eventBus.publish(TripStateChangedEvent( tripId: trip.id, fromState: currentState, toState: targetState, timestamp: now() )) // Execute side effects (outside transaction) await executeSideEffects(trip, currentState, targetState, context) return trip async function validateTransition(trip, targetState, context): match targetState: case TRIP_STARTED: # Can only start if driver is at pickup if not trip.driverArrivedAt: raise ValidationError("Driver hasn't arrived") case TRIP_COMPLETED: # Can only complete if trip was started if trip.state != TRIP_STARTED: raise ValidationError("Trip not in progress") # Must have valid destination reached if not context.get("locationConfirmed"): raise ValidationError("Destination not confirmed") case NO_SHOW: # Driver must have waited minimum time waitTime = now() - trip.driverArrivedAt if waitTime < MIN_WAIT_FOR_NOSHOW: # e.g., 5 minutes raise ValidationError(f"Must wait {MIN_WAIT_FOR_NOSHOW - waitTime} more")Transitions should be atomic and create an immutable history. Never update a trip's state in place without recording the transition. This audit trail is essential for dispute resolution, debugging, and compliance.
Modern trip management systems often use event sourcing—storing the sequence of events that changed trip state rather than just the current state.
Traditional CRUD: Store current trip state. History is lost or stored separately.
Event Sourcing: Store every event that happened. Current state is derived by replaying events.
1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586
// Event definitionsabstract class TripEvent: tripId: UUID eventId: UUID // For idempotency timestamp: DateTime version: Int // Sequence number within trip class TripRequestedEvent(TripEvent): riderId: UUID pickup: Location dropoff: Location requestedVehicleType: VehicleType fareEstimate: Money class DriverAssignedEvent(TripEvent): driverId: UUID vehicleInfo: Vehicle estimatedPickupTime: Duration class DriverArrivedEvent(TripEvent): arrivalLocation: Location actualPickupTime: Duration class TripStartedEvent(TripEvent): startLocation: Location class TripCompletedEvent(TripEvent): endLocation: Location actualRoute: Polyline distanceMeters: Int durationSeconds: Int class FareCalculatedEvent(TripEvent): baseFare: Money distanceCharge: Money timeCharge: Money surgeMultiplier: Float totalFare: Money class PaymentProcessedEvent(TripEvent): paymentMethodId: UUID amount: Money transactionId: String status: PaymentStatus // Event store interfaceclass TripEventStore: async function append(tripId, event): """Append event to trip's event stream.""" async with database.transaction(): # Get current version currentVersion = await getLatestVersion(tripId) # Set event version (optimistic concurrency) event.version = currentVersion + 1 # Append to event stream await database.insert("trip_events", { trip_id: tripId, event_id: event.eventId, event_type: event.getClass().name, version: event.version, timestamp: event.timestamp, payload: event.toJson() }) return event.version async function getEvents(tripId, fromVersion = 0): """Get all events for a trip, optionally from a specific version.""" return await database.query(""" SELECT * FROM trip_events WHERE trip_id = $1 AND version > $2 ORDER BY version ASC """, [tripId, fromVersion]) async function rebuildState(tripId): """Rebuild current trip state by replaying all events.""" events = await getEvents(tripId) trip = Trip() for event in events: trip.apply(event) # Each event type knows how to update state return tripEvent sourcing makes querying current state expensive (must replay events). Projections solve this by maintaining queryable views updated in response to events:
12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849
class TripProjection: """ Maintains a queryable view of trips updated from events. """ @eventHandler(TripRequestedEvent) async function onTripRequested(event): await database.insert("trips_current", { id: event.tripId, rider_id: event.riderId, status: "REQUESTED", pickup: event.pickup, dropoff: event.dropoff, created_at: event.timestamp }) @eventHandler(DriverAssignedEvent) async function onDriverAssigned(event): await database.update("trips_current", where: { id: event.tripId }, set: { driver_id: event.driverId, status: "MATCHED" } ) @eventHandler(TripCompletedEvent) async function onTripCompleted(event): await database.update("trips_current", where: { id: event.tripId }, set: { status: "COMPLETED", actual_distance: event.distanceMeters, actual_duration: event.durationSeconds, end_location: event.endLocation, completed_at: event.timestamp } ) // Also update analytics projection await updateRiderTripsAnalytics(event.tripId) await updateDriverTripsAnalytics(event.tripId) // Query the projection, not the event storeasync function getActiveTripsForCity(cityId): return await database.query(""" SELECT * FROM trips_current WHERE city_id = $1 AND status IN ('REQUESTED', 'MATCHED', 'IN_PROGRESS') """, [cityId])Many organizations use a hybrid approach: event sourcing for the core trip lifecycle (where audit trails matter) combined with traditional CRUD for less critical data. This balances the benefits of event sourcing with the simplicity of traditional patterns.
Payment handling in ride-sharing is complex because it spans the entire trip lifecycle and involves multiple external systems.
1. Pre-Authorization (Before Trip)
Place a hold on rider's payment method for estimated fare + buffer:
12345678910111213141516171819202122232425262728293031323334353637383940414243
class PaymentService: async function authorizeTrip(tripId, riderId, fareEstimate): # Get rider's default payment method paymentMethod = await getDefaultPaymentMethod(riderId) if not paymentMethod: raise NoPaymentMethodError() # Calculate auth amount with buffer # Buffer covers: surge increases, route changes, tips, tolls bufferPercent = 0.30 # 30% buffer authAmount = fareEstimate * (1 + bufferPercent) authAmount = roundUp(authAmount, 5) # Round up to nearest $5 # Create authorization with idempotency key idempotencyKey = f"trip-auth-{tripId}" try: authResult = await paymentGateway.authorize( paymentMethodId: paymentMethod.id, amount: authAmount, currency: paymentMethod.currency, idempotencyKey: idempotencyKey, metadata: {tripId: tripId} ) # Store authorization for later capture await database.insert("trip_authorizations", { trip_id: tripId, auth_id: authResult.id, auth_amount: authAmount, payment_method_id: paymentMethod.id, status: "AUTHORIZED", created_at: now(), expires_at: now() + 7.days }) return {success: true, authId: authResult.id} except InsufficientFundsError: return {success: false, reason: "insufficient_funds"} except PaymentMethodDeclinedError: return {success: false, reason: "card_declined"}2. Fare Calculation
After trip completion, calculate the actual fare:
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748
class FareCalculator: async function calculateFare(trip): # Get pricing rules for city/vehicle type pricing = await getPricingRules(trip.cityId, trip.vehicleType) # Base calculations baseFare = pricing.baseFare distanceCharge = trip.actualDistanceKm * pricing.perKmRate timeCharge = trip.actualDurationMinutes * pricing.perMinuteRate # Minimum fare calculatedFare = baseFare + distanceCharge + timeCharge calculatedFare = max(calculatedFare, pricing.minimumFare) # Surge multiplier (stored at trip start) surgeMultiplier = trip.surgeMultiplierAtRequest fareAfterSurge = calculatedFare * surgeMultiplier # Additional charges tolls = await calculateTolls(trip.actualRoute) airportFee = await getAirportSurchargeIfApplicable(trip) # Total before promotions subtotal = fareAfterSurge + tolls + airportFee # Apply promotions (rider credits, promo codes) promotionDiscount = await applyPromotions(trip.riderId, subtotal) # Final rider charge riderCharge = subtotal - promotionDiscount # Calculate driver earnings platformFee = riderCharge * pricing.platformFeePercent # e.g., 25% driverEarnings = riderCharge - platformFee + tolls # Tolls pass-through return FareBreakdown( baseFare: baseFare, distanceCharge: distanceCharge, timeCharge: timeCharge, surgeAmount: fareAfterSurge - calculatedFare, tolls: tolls, airportFee: airportFee, promotionDiscount: promotionDiscount, riderCharge: riderCharge, platformFee: platformFee, driverEarnings: driverEarnings )3. Payment Capture
Capture the actual fare from the pre-authorized hold:
12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152
class PaymentService: async function capturePayment(tripId, fareBreakdown): # Get authorization auth = await database.get("trip_authorizations", tripId) if auth.status != "AUTHORIZED": raise InvalidAuthStateError(auth.status) if now() > auth.expires_at: raise AuthExpiredError() # Idempotency key for capture idempotencyKey = f"trip-capture-{tripId}" try: # Capture the actual amount (releases excess hold automatically) captureResult = await paymentGateway.capture( authorizationId: auth.auth_id, amount: fareBreakdown.riderCharge, idempotencyKey: idempotencyKey ) # Record successful capture await database.update("trip_authorizations", tripId, { status: "CAPTURED", captured_amount: fareBreakdown.riderCharge, captured_at: now() }) # Queue driver payout await queueDriverPayout(tripId, fareBreakdown.driverEarnings) # Send receipt await sendReceiptEmail(trip, fareBreakdown) return {success: true, transactionId: captureResult.id} except PaymentError as e: # Handle failure - will retry await database.update("trip_authorizations", tripId, { status: "CAPTURE_FAILED", failure_reason: e.message }) # Queue for retry await retryQueue.enqueue("payment_capture_retry", { tripId: tripId, attempt: 1 }, delay=5.minutes) return {success: false, reason: e.message}Payment operations MUST be idempotent. Network failures, service restarts, or retries could cause duplicate capture attempts. Without idempotency keys, you might charge a rider twice or pay a driver twice. Payment gateways use idempotency keys to deduplicate requests.
Distributed systems fail constantly. Trip management must handle failures gracefully without leaving trips in inconsistent states.
| Scenario | Detection | Recovery Strategy |
|---|---|---|
| Driver app crashes mid-trip | Location updates stop | Attempt reconnection; allow resumption within 5 min |
| Rider app crashes | No heartbeat | Trip continues; rider can rejoin |
| Payment capture fails | Gateway error | Retry with exponential backoff; alert support after 3 failures |
| Matching service unavailable | Timeout on match | Queue request; notify rider of delay |
| Driver doesn't respond to offer | Offer timeout (15s) | Mark driver as unavailable; try next driver |
| Database fails after state change | Write error | Event sourcing replay reconstructs state |
| Trip stuck in state | Watchdog timeout | Escalate to ops; attempt automated resolution |
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384
class TripFailureHandler: async function handleDriverDisconnection(tripId, driverId): """Handle when driver's app loses connection during active trip.""" trip = await getTrip(tripId) if trip.state not in [DRIVER_EN_ROUTE, DRIVER_ARRIVED, TRIP_STARTED]: return # Not in active trip phase # Record disconnection await recordEvent(TripEvent.DRIVER_DISCONNECTED, tripId, { timestamp: now(), lastKnownLocation: getLastDriverLocation(driverId) }) # Start grace period timer await scheduler.scheduleAt( now() + RECONNECTION_GRACE_PERIOD, # 5 minutes "check_driver_reconnection", {tripId, driverId} ) # Notify rider await notifyRider(trip.riderId, "We're having trouble connecting to your driver. " + "Trip will continue once connection is restored." ) @scheduledHandler("check_driver_reconnection") async function checkDriverReconnection(tripId, driverId): trip = await getTrip(tripId) # Check if driver reconnected lastUpdate = await getLastLocationUpdate(driverId) if lastUpdate.timestamp > now() - 1.minute: return # Driver is back, all good # Driver still disconnected after grace period if trip.state == TRIP_STARTED: # Trip was in progress - need resolution await escalateToSupport(tripId, "DRIVER_DISCONNECTION_DURING_TRIP") # Potentially auto-complete trip at last known location elif trip.state in [DRIVER_EN_ROUTE, DRIVER_ARRIVED]: # Haven't started yet - reassign await transitionTrip(tripId, DRIVER_CANCELED, { reason: "DRIVER_DISCONNECTION", automated: true }) # Re-enter matching to find new driver await triggerRematching(tripId) class PaymentRetryHandler: MAX_RETRY_ATTEMPTS = 5 RETRY_DELAYS = [5.minutes, 15.minutes, 1.hour, 6.hours, 24.hours] @queueHandler("payment_capture_retry") async function retryPaymentCapture(tripId, attempt): trip = await getTrip(tripId) fareBreakdown = await getFareBreakdown(tripId) result = await paymentService.capturePayment(tripId, fareBreakdown) if result.success: await transitionTrip(tripId, COMPLETED) return if attempt >= MAX_RETRY_ATTEMPTS: # Exhausted retries await escalateToFinance(tripId, "PAYMENT_CAPTURE_EXHAUSTED", { attempts: attempt, lastError: result.reason }) # Mark as requires manual resolution await markTripForManualResolution(tripId, "PAYMENT_FAILURE") return # Schedule next retry nextDelay = RETRY_DELAYS[min(attempt, len(RETRY_DELAYS) - 1)] await retryQueue.enqueue("payment_capture_retry", { tripId: tripId, attempt: attempt + 1 }, delay=nextDelay)Trip completion involves multiple services (trip, payment, notifications, analytics). We can't use traditional ACID transactions across services. The Saga pattern provides eventual consistency through compensating actions:
12345678910111213141516171819202122232425262728293031323334353637383940414243
class TripCompletionSaga: """ Orchestrates trip completion across multiple services. Each step has a compensating action if subsequent steps fail. """ steps = [ Step("calculate_fare", calculateFare, compensate=None), Step("capture_payment", capturePayment, compensate=reversePayment), Step("queue_driver_payout", queuePayout, compensate=cancelPayout), Step("update_trip_status", markCompleted, compensate=revertStatus), Step("send_notifications", sendAllNotifications, compensate=None), Step("update_analytics", sendAnalytics, compensate=None), ] async function execute(tripId): context = {tripId: tripId, completedSteps: []} for step in steps: try: result = await step.action(context) context[step.name + "_result"] = result context.completedSteps.append(step.name) except StepError as e: # Step failed - compensate completed steps in reverse await compensate(context, e) raise SagaFailedError(tripId, step.name, e) return context async function compensate(context, originalError): # Execute compensating actions in reverse order for stepName in reversed(context.completedSteps): step = getStep(stepName) if step.compensate: try: await step.compensate(context) except CompensationError as e: # Compensation failed - needs manual intervention await alertOps(f"Compensation failed for {stepName}: {e}") await recordSagaFailure(context, originalError)The saga above uses orchestration (central coordinator). Alternatively, choreography has each service emit events that trigger the next step. Orchestration is easier to understand and debug; choreography is more decoupled but harder to trace. Uber uses orchestration for critical flows like trip completion.
Users expect to see their driver's location updating in real-time. This requires efficient push infrastructure.
1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071
class TripTrackingService: """ Handles real-time trip updates to rider app. """ @eventHandler(DriverLocationUpdated) async function onDriverLocationUpdate(event): # Get active trips for this driver activeTrips = await getTripsByDriver(event.driverId, statuses=[DRIVER_EN_ROUTE, DRIVER_ARRIVED, TRIP_STARTED]) for trip in activeTrips: # Calculate updated ETA if trip.state == DRIVER_EN_ROUTE: newETA = await etaService.getPickupETA( event.location, trip.pickupLocation ) elif trip.state == TRIP_STARTED: newETA = await etaService.getArrivalETA( event.location, trip.dropoffLocation ) else: newETA = null # Create update message update = TripLocationUpdate( tripId: trip.id, driverLocation: event.location, heading: event.heading, eta: newETA, timestamp: event.timestamp ) # Push to rider await pushService.sendToUser(trip.riderId, update, { priority: "high", channel: "websocket", # Prefer WebSocket if connected ttl: 10.seconds # Expire quickly if not delivered }) class PushService: websocketGateway: WebSocketGateway apnsClient: APNSClient fcmClient: FCMClient async function sendToUser(userId, message, options): user = await getUser(userId) # Try WebSocket first (if app is open) if options.channel == "websocket" or options.channel == "any": wsConnections = await websocketGateway.getConnections(userId) if wsConnections.length > 0: for conn in wsConnections: await conn.send(message) return {delivered: true, channel: "websocket"} # Fall back to push notifications if user.platform == "ios": await apnsClient.send(user.pushToken, message, { priority: options.priority, expiry: now() + options.ttl }) else: await fcmClient.send(user.pushToken, message, { priority: options.priority, ttl: options.ttl.seconds }) return {delivered: true, channel: "push"}Riders can share their trip with contacts who see a read-only view:
| Data | Shared View Access | Justification |
|---|---|---|
| Driver first name, photo | Yes | Safety - contact knows who is driving |
| Driver phone number | No | Privacy - only available to rider |
| Real-time location | Yes | Core feature purpose |
| Exact pickup address | Configurable by rider | May reveal home address |
| Fare amount | No | Financial privacy |
| Historical trips | No | Only current trip is shared |
Let's consolidate the complete trip management architecture:
| Service | Responsibilities | Dependencies |
|---|---|---|
| Trip Service | Lifecycle management, state machine, event sourcing | Location, Matching, Payment, Notification |
| Matching Service | Driver discovery, scoring, assignment | Location, ETA, Driver |
| Payment Service | Auth, capture, payouts, receipts | Payment Gateway, Trip |
| Notification Service | Push, email, SMS delivery | User, Trip |
| Location Service | Real-time location tracking | Redis, Kafka |
| ETA Service | Route calculation, time estimation | Routing Engine, Traffic Data |
| Pricing Service | Fare calculation, surge | Trip, Location |
| Data Type | Store | Justification |
|---|---|---|
| Trip records & events | PostgreSQL (sharded) | ACID for financial data, SQL for queries |
| Real-time driver location | Redis | Sub-ms reads, TTL expiration |
| Event stream | Kafka | Durable, ordered, fan-out to consumers |
| Trip analytics | BigQuery/Redshift | OLAP workloads, historical analysis |
| Session state | Redis | Fast access, auto-expiration |
| Document search (support) | Elasticsearch | Full-text search for tickets |
Trip management is the orchestration layer that ties all ride-sharing components together. Let's consolidate the key learnings:
| Page | Topic | Core Learning |
|---|---|---|
| Page 1 | Requirements & Matching | Two-sided marketplace, scale estimation, matching problem framing |
| Page 2 | Location Tracking | Geospatial indexing, Redis for real-time, streaming ingestion |
| Page 3 | Matching Algorithm | Multi-factor scoring, batch optimization, concurrency control |
| Page 4 | Surge Pricing | Supply/demand equilibrium, geographic zones, temporal smoothing |
| Page 5 | ETA Calculation | Contraction Hierarchies, real-time traffic, ML enhancement |
| Page 6 | Trip Management | State machines, event sourcing, payment orchestration |
Congratulations! You now have a comprehensive understanding of how to design a ride-sharing platform. You can articulate the requirements, design real-time location tracking, implement efficient matching, compute dynamic pricing, calculate accurate ETAs, and orchestrate the complete trip lifecycle. This knowledge applies beyond ride-sharing to any real-time, location-aware, two-sided marketplace.