04 · Architecture

Simple to operate today. Ready to scale tomorrow.

Our prototype is built on a deliberately boring, proven stack: a modular monolith, PostgreSQL + PostGIS, Redis and background workers. That gives us speed to launch and a clean path to scale.

System overview

How the pieces fit together.

Clients call a stateless API. Heavy geospatial work and deliveries go through Redis queues to a separate worker process. Everything persists in PostgreSQL with PostGIS.

CLIENTSPassenger appExpo · iOS · AndroidPilot appsame app · driver modeWeb & adminNext.js · KYC reviewExpo API routesBFF proxy · no secretsBearer JWT + Idempotency-Keyon every mutationAPI PROCESS · BUN + EXPRESSMiddleware chainrequest-id · auth · RBAC · rate limit · idempotency · ZodAuthOTP · JWT · refreshUsers & PilotsKYC · vehicles · docsCommute routesoffers & requestsMatchingmatch cards · acceptRidesstate machine · SOSPaymentsRazorpay · webhooksNotificationsin-app inbox · pushAnalyticssavings · CO₂Modular monolith — modules talk only via index.tsstateless · horizontally scalable · /health/readyREDIS · BULLMQ QUEUESmatching3 retries · exp. backoffnotificationsdecoupled deliveryrequest-expiryrepeat sweep · 60 sWORKER PROCESSMatching workerPostGIS + scoringNotification workerinbox + push hookExpiry workerwindows · recurringDATAPostgreSQL + PostGISGIST spatial indexes · DrizzleRedisOTP · tokens · idempotencyINTEGRATIONSOSRMroad routingNominatimgeocodingRazorpayUPI payments2FactorSMS OTPS3 / R2KYC documentsFCMpush (next)enqueueread/writeSQL · DrizzleHTTPS · signed webhooks
Pair My Ride architecture as built in the prototype: API and workers scale independently and share Redis and Postgres

Client layer

One Expo / React Native codebase ships to iOS and Android. Tokens live in SecureStore, and Expo API routes proxy sensitive calls so secrets never reach the device.

API layer

A Bun + Express modular monolith. Each domain (auth, users, routes, matching, rides, payments, notifications) exposes only its index.ts, which keeps the boundaries ready for a service split.

Async layer

BullMQ on Redis runs matching, notification and request-expiry queues in a separate worker process that scales independently of the API.

Data layer

PostgreSQL with PostGIS for geometry and GIST indexes, managed through Drizzle ORM migrations. Redis handles OTPs, refresh tokens, rate limits and idempotency.

Data model

Twelve tables that model the entire commute economy.

Routes are both offers and requests, distinguished by is_driver. Matches link two routes with a score. Rides and payments follow from an accepted match.

1:n1:n1:n2 × n1:n1:n1:nn:n1:nuser_documentsprovider · aadhaar / DL / RCfront / back image (S3)verified · verified_byusersphone (unique)role · driver / passenger / bothtrust_score · total_ridesis_verified · fcm_tokencommute_routesorigin / dest (PostGIS Point)route_line (LineString)depart window · active_daysis_driver · seats · request_statusmatchesdriver_route · passenger_routeroute / time overlap %detour_km · scorestatus · expires_atvehiclestype · model · numberseating · fuel typeRC / insurance / PUC docsstatus · under_reviewcommute_partnersuser ↔ partnerstatus · total_ridessavings_snapshotsmonthfuel_saved_litersmoney_saved_paise · co2_kgridesride_code · statuspickup / dropoff pointstimestamps per statefare_paise · cancel reasonnotificationsuser_id → userstype · match_found …title · body · datais_readwalletsuser_id → users (1:1)balance_paiseratingsrater → rateescore 1–5 · commentpaymentsamount / commission paisedriver_payout_paisemethod · UPI / walletgateway_ref · status
Core entities from db/schema.ts (PostgreSQL + PostGIS, managed with Drizzle)
Ride & money flow

Strict state machines. No ambiguous rides, no lost rupees.

Every ride follows an enforced state machine with a timestamp for each transition. Payments settle through Razorpay with signature verification, and the pilot/platform split is computed in integer paise.

RIDE STATE MACHINEscheduledmatch accepteddriver_en_routepilot heading outdriver_arrivedat pickupin_progresslive tracking · SOScompletedfare lockedcancelledreason required · min 3 charsInvalid transitions rejected with 409 INVALID_STATE_TRANSITIONPAYMENT SETTLEMENTRazorpay orderPOST /payments/rides/:id/orderUPI checkoutGPay · PhonePe · Paytm · CREDSignature verifiedconfirm + signed webhookSettled in paise97% pilot · 3% platformpay
Security & reliability

Built like a fintech, because it moves money.

  • Phone OTP login with short-lived JWT access tokens and rotating refresh tokens
  • Role-based authorisation for rider, pilot and both
  • Idempotency-Key on every mutating request, so mobile retries never double-book or double-charge
  • Rate limiting and request IDs on every call for abuse protection and traceability
  • Zod validation at the edge of every endpoint
  • Razorpay signature verification plus a raw-body signed webhook
  • All money stored as integer paise, with no floating-point errors
  • KYC images in S3 / R2. The database stores only URLs and verification metadata
Failure modes, handled
Worker down
Jobs queue safely in Redis and drain when the worker restarts.
Job throws
Retried 3× with exponential backoff, and failures are logged.
Duplicate submit
The idempotency key returns the original response.
Redis blip on enqueue
The route is saved, and the rider can re-trigger search.
Graceful shutdown
SIGTERM finishes in-flight jobs, then closes Redis and the DB pool.
Technology stack

Modern, cost-efficient, and hireable.

Mobile
Expo SDK 54React Native 0.81React 19Expo RouterReanimatedSecureStore
API
Bun runtimeTypeScriptExpressZod validationJWT + refresh rotationIdempotency keys
Data
PostgreSQLPostGIS (GIST)Drizzle ORMRedis
Async
BullMQMatching workerNotification workerRequest-expiry worker
Geo
Self-hosted OSRMNominatim geocodingRoute polylines
Integrations
Razorpay UPI2Factor OTPS3 / Cloudflare R2FCM (next)
Scaling path

Each bottleneck already has a planned next step.

The primary scaling metric is matching queue depth. Here is how each layer evolves as we grow city by city.

LayerIn the prototypeAt city scale
APIStateless Bun instances behind a load balancerAuto-scaled replicas + PgBouncer connection pooling
MatchingSingle worker process, ≤ 100 candidates per jobN worker replicas on the same queue, H3 geo-bucketing before PostGIS
GeometryEndpoint radius + LineString corridor (ST_DWithin)Shared-length overlap via ST_Intersects on route lines
NotificationsIn-app inbox via dedicated queueFCM push adapter behind the same queue, no matching changes
ReadsDirect Postgres readsCache-aside for hot profiles and saved routes
ObservabilityStructured logs, /health/ready, queue failure hooksQueue-depth alerts (> 30 s wait), k6 load tests on matching