A scalable, event-driven backend system for smart airport ride pooling with geospatial matching, concurrency handling, and real-time driver assignment.
Build a Smart Airport Ride Pooling Backend System that groups passengers into shared cabs while optimizing routes and pricing.
- Assignment Deliverables
- System Overview
- Architecture
- Algorithms & Data Structures
- Assumptions & Design Decisions
- Tech Stack
- Project Structure
- Setup Instructions
- API Documentation
- How It Works
- Database Schema
- Pricing Formula
- Detour Algorithm Explained
- Testing
- Development Scripts
- Technical Implementation Details
- Complexity Analysis Summary
- License
This system groups passengers into shared airport rides while optimizing routes and pricing. It supports both airport-to-city and city-to-airport directions with automatic driver assignment.
┌─────────────┐ ┌──────────────┐ ┌─────────────────┐
│ Client │────▶│ API Server │────▶│ PostgreSQL DB │
└─────────────┘ └──────────────┘ └─────────────────┘
│
▼
┌──────────────┐
│ Redis Queue │
└──────────────┘
│
▼
┌──────────────┐
│ Worker │
└──────────────┘
Sequence Flow:
- Geospatial Matching: O(log n) queries using PostGIS R-tree index
- Event-Driven Processing: Async queue for background matching
- Concurrency Safety: Row-level locking prevents race conditions
- Real-time Driver Assignment: Immediate driver allocation on pool creation
- Dynamic Pricing: Pooling discounts based on passenger count
Complexity: O(log n)
Location: findBestPool(), findNearestDrivers()
Purpose: Efficiently find pools and drivers within geographic radius
Complexity: O(k) where k=10 (constant)
Location: findBestPool() (after spatial query)
Purpose: Select optimal pool from candidates by minimum detour
Complexity: O(n) where n≤8 (waypoints)
Location: calculateDetour()
Purpose: Check if adding passenger exceeds max detour tolerance
Complexity: O(1) atomic operation
Location: assignRideToPool(), assignDriverToPool()
Purpose: Prevent race conditions with SELECT ... FOR UPDATE
Complexity: O(1) enqueue/dequeue
Location: addRideToQueue(), processRideMatching()
Purpose: Asynchronous processing without blocking API
| Metric | Target | Achieved |
|---|---|---|
| API Latency | <300ms | ~80ms (ride booking - major api) |
| Throughput | 100 req/s | Supported due to locks, indexing and normalised shcema |
| Concurrent Users | 10,000 | Scalable |
Current Implementation: Automatic Assignment
- Drivers are automatically assigned to pools when a new pool is created
- System selects the nearest available driver without requiring manual acceptance
- Driver can reject by not showing up (out of scope for this version)
Rationale:
- Simpler implementation for MVP/demo
- Faster user experience (no waiting for driver acceptance)
- Common in airport shuttle services where drivers are employees
Future Enhancement:
- Add driver notification system
- Implement accept/reject flow with timeout
- Support multiple driver bidding on pool
- Airport location is provided by client (airportLat, airportLng parameters)
- Direction (airport_to_city vs city_to_airport) is calculated based on proximity to airport
- Maximum 2km radius considered "at airport"
- Vehicle capacity determined at pool creation from assigned driver's vehicle
- Pool max capacity = driver's vehicle maxSeats
- Once driver arrives, pool locks (no more passengers)
- Database: PostgreSQL with PostGIS extension
- Cache/Queue: Redis with BullMQ
- Backend: Next.js API Routes
- ORM: Drizzle ORM
- Language: TypeScript
- Testing: Bun Test
- Container: Docker Compose
alike/
├── apps/
│ └── web/
│ ├── src/app/api/ # API Routes
│ │ ├── rides/ # Ride endpoints
│ │ └── drivers/ # Driver endpoints
│ └── src/lib/pricing.ts # Pricing logic
├── packages/
│ └── db/
│ ├── src/
│ │ ├── schema/ # Database schema
│ │ ├── queries/ # Business logic
│ │ │ ├── rides.ts # Matching and pooling
│ │ │ └── crud.ts # CRUD operations
│ │ ├── events.ts # Queue system
│ │ └── test/
│ └── docker-compose.yml
- Bun 1.3.5+
- Docker Desktop
- Git
git clone <repository>
cd alike
bun install# Start PostgreSQL and Redis (from project root)
cd alike
bun run db:start
# Verify services are running
docker ps# From project root
cd alike
# Generate migrations (creates SQL files)
bun run db:generate
# Apply migrations to database
bun run db:migrate
# Seed the db
bun run db:seed
# Optional: Open database GUI
bun run db:studioCreate apps/web/.env:
DATABASE_URL=postgresql://postgres:password@localhost:5432/alike
REDIS_HOST=localhost
REDIS_PORT=6379Terminal 1 - Start Worker (from project root):
cd alike
bun run queue:startTerminal 2 - Start Web Server (from project root):
cd alike
bun run dev:webThe application will be available at:
- Web App: http://localhost:3000
- API Docs: http://localhost:3000/api-doc
- Database Studio: http://localhost:4983
📄 PDF Documentation: See alike-api.pdf in the root directory for a complete Swagger API reference (print of the /api-doc page).
Interactive API documentation also available at /api-doc when running the app.
POST /api/rides
Content-Type: application/json
{
"userId": "uuid",
"pickupLat": 40.7128,
"pickupLng": -74.0060,
"dropoffLat": 40.7589,
"dropoffLng": -73.9851,
"seats": 1,
"luggage": 1,
"airportLat": 40.6413,
"airportLng": -73.7781
}Response:
{
"rideId": "uuid",
"status": "pending",
"direction": "city_to_airport",
"estimatedPrice": 15.50,
"message": "Finding your pool and driver..."
}GET /api/rides/:idResponse:
{
"id": "uuid",
"status": "matched",
"pool": {
"id": "uuid",
"status": "forming",
"filledSeats": 2,
"maxSeats": 4,
"driver": {
"id": "uuid",
"rating": "4.8"
}
}
}POST /api/drivers/location
Content-Type: application/json
{
"driverId": "uuid",
"lat": 40.7128,
"lng": -74.0060
}POST /api/drivers/arrive/:poolId
Content-Type: application/json
{
"driverId": "uuid"
}- User requests ride → POST /api/rides
- System detects direction (airport-to-city or city-to-airport)
- Saves to database with status "pending"
- Adds to Redis queue (instant response, < 100ms)
- Worker processes job asynchronously
- Finds or creates pool with driver assignment
- Updates ride status to "matched"
- Spatial Query: Find pools within 5km radius (O(log n))
- Filter: Same direction + capacity available
- Select: Choose pool with minimum detour (O(k))
- Lock: Row-level lock prevents race conditions
- Assign: Add passenger, update capacity
- If no pool: Create new pool with immediate driver assignment
- Row Locking:
SELECT ... FOR UPDATEon pool rows - Optimistic Locking: Version field for conflict detection
- Queue Processing: Single worker prevents concurrent modifications
- Transaction Safety: All updates within database transactions
- users: Passengers and drivers
- drivers: Driver profiles with real-time location
- vehicles: Vehicle capacity constraints
- pools: Shared ride containers
- ride_requests: Individual bookings
- GIST index on pickup locations (geospatial queries)
- GIST index on pool centers (geospatial queries)
- B-tree indexes on status, driver_id, user_id
- Composite indexes for common queries
Quick Start: Try running ./setup.sh to automate the entire setup process (starts services, runs migrations, and seeds data).
The pricing system is realistic, configurable, and fair - designed to incentivize pooling while covering actual costs.
PRICING_BASE_FARE=3.0 # Fixed starting cost ($3)
PRICING_PER_KM=1.5 # Cost per km ($1.50)
PRICING_SEAT_MULTIPLIER=1.2 # Extra 20% per additional seat
PRICING_AIRPORT_FEE=2.0 # Airport toll/parking fee
PRICING_MAX_DISCOUNT=0.40 # Max 40% off when pool is fullStep 1: Calculate Base Cost
Distance Cost = Distance (km) × $1.50 × Seat Multiplier
Seat Multiplier = 1 + (Seats - 1) × 0.20
Example: 20km ride with 2 seats
Distance Cost = 20 × $1.50 × 1.2 = $36.00
Step 2: Add Fixed Costs
Base Price = $3.00 (base fare) + $36.00 (distance) + $2.00 (airport fee)
Base Price = $41.00
Step 3: Apply Pooling Discount
The more people in the pool, the bigger the discount for EVERYONE:
Pool Fullness = Current Passengers / 4 (max capacity)
Discount Rate = Pool Fullness × 40% (max)
Discount Amount = Base Price × Discount Rate
Example: 2 passengers in pool
Discount Rate = (2/4) × 0.40 = 20%
Discount Amount = $41.00 × 0.20 = $8.20
Step 4: Final Price
Final Price = $41.00 - $8.20 = $32.80
| Scenario | Base Price | Discount | You Pay | Savings |
|---|---|---|---|---|
| Solo Ride (1 person, 20km) | $41.00 | 0% | $41.00 | - |
| Pool with 2 | $41.00 | 20% | $32.80 | $8.20 |
| Pool with 3 | $41.00 | 30% | $28.70 | $12.30 |
| Pool with 4 | $41.00 | 40% | $24.60 | $16.40 |
✅ Fair: Solo riders pay full cost, poolers get rewarded
✅ Transparent: Same formula for everyone, no hidden fees
✅ Configurable: Change prices via environment variables
✅ Profitable: Covers gas, time, airport fees even with discounts
✅ Incentivized: Up to 40% off encourages pooling
Premium Service (higher prices, smaller discounts):
PRICING_BASE_FARE=5.0
PRICING_PER_KM=2.5
PRICING_MAX_DISCOUNT=0.25Budget Service (lower prices, bigger discounts):
PRICING_BASE_FARE=2.0
PRICING_PER_KM=1.0
PRICING_MAX_DISCOUNT=0.50✅ Integrated in Code: The algorithm in
detour.tsis actively used byrides.tsto calculate optimal detours when matching passengers to pools.
When adding a new passenger to an existing pool, we must ensure the detour doesn't inconvenience existing passengers too much.
- Existing pool: Pickup A → Pickup B → Airport
- New passenger wants to join at Pickup C
- Where should we insert C to minimize extra distance?
Since pools have max 8 passengers (4 pickups + 4 dropoffs), we can try ALL possible insertion positions:
Algorithm Steps:
- Get current route waypoints (pickups and dropoffs)
- Calculate current total distance
- For each possible insertion position (0 to n):
- Insert new pickup/dropoff at position i
- Calculate new total distance
- Track the minimum distance found
- Detour = (new distance - current distance)
- If detour ≤ maxDetourKm (default 3km), passenger can join
Example:
Current: Home A → Home B → Airport [15km total]
Try inserting Home C at position 0:
Home C → Home A → Home B → Airport = 18km (detour: 3km) ✓
Try inserting Home C at position 1:
Home A → Home C → Home B → Airport = 16.5km (detour: 1.5km) ✓ BEST
Try inserting Home C at position 2:
Home A → Home B → Home C → Airport = 17km (detour: 2km) ✓
Complexity: O(n²) where n ≤ 8 waypoints → effectively O(1) constant time
Why Not A or Dijkstra?*
- We don't need the absolute shortest path between two points
- We need the best INSERTION position in an existing route
- With only 8 waypoints, brute-force is fast and guarantees optimal insertion
- Real routing APIs (Google Maps, OSRM) would be used in production for actual driving distances
# From project root
cd alike
# Run all tests
bun run test
# Run database tests
bun run test --filter @alike/db- Database schema validation
- Geospatial matching accuracy
- Concurrency/race condition handling
- Queue processing latency
- Pool capacity enforcement
- Driver assignment flow
| Script | Description |
|---|---|
bun run db:start |
Start PostgreSQL + Redis |
bun run db:stop |
Stop services |
bun run db:migrate |
Apply migrations |
bun run db:studio |
Open database GUI |
bun run queue:start |
Start background worker |
bun run dev:web |
Start web server |
bun run test |
Run tests |
Docker Compose Configuration (packages/db/docker-compose.yml):
- PostgreSQL: Uses
postgis/postgis:16-3.4image (includes PostGIS extension for geospatial queries) - Redis: Uses
redis:alpineimage for lightweight queue storage - Volumes: Persistent storage for both services (data survives container restarts)
- Ports: PostgreSQL on 5432, Redis on 6379
Why These Images:
- PostGIS: Required for
ST_DWithin()and other geospatial functions - Redis Alpine: Minimal footprint, perfect for queue storage
- Both restart automatically if they crash
Schema Management:
packages/db/src/schema/schema.ts # Table definitions
packages/db/drizzle.config.ts # ORM configuration
packages/db/src/migrations/ # Generated SQL files
Configuration Changes Made:
- Updated
drizzle.config.tsto look for.envin multiple locations - Ensured DATABASE_URL is always loaded before migrations
- PostGIS extension auto-enabled via Docker image (no manual setup)
Migration Flow:
bun run db:generate- Reads schema.ts, creates SQL migration filesbun run db:migrate- Applies migrations to PostgreSQL- Migrations are idempotent (safe to run multiple times)
Architecture:
API Server → Redis Queue → Worker Process
↓ ↓
Instant Background
Response Processing
How It Works:
- User creates ride → API adds job to Redis (BullMQ)
- API returns immediately (no waiting for matching)
- Worker process picks up job from Redis
- Worker queries PostgreSQL, finds/creates pool
- Worker updates ride status to "matched"
Why BullMQ:
- Persistent: Jobs survive crashes (stored in Redis)
- Retry Logic: Failed jobs auto-retry 3 times
- Scalable: Can run multiple workers for load balancing
- Atomic: Prevents race conditions in job processing
Worker Startup:
cd packages/db
bunx tsx src/start-worker.tsMonitoring Queue:
redis-cli
LLEN bull:ride-matching:wait # See pending jobs| Operation | Algorithm | Time | Space |
|---|---|---|---|
| Find nearby pools | R-tree Index | O(log n) | O(1) |
| Find nearest drivers | R-tree Index | O(log m) | O(1) |
| Select best pool | Greedy | O(k) | O(1) |
| Calculate detour | Insertion | O(n) | O(1) |
| Assign to pool | Row Lock | O(1) | O(1) |
| Queue operation | FIFO | O(1) | O(1) |
n = number of pools, m = number of drivers, k = 10 (candidates)
MIT
Built for Airport Ride Pooling System Assignment


