Building MURI
How I architected a student transportation platform serving three roles — client, driver, and admin — each with its own app, backed by a modular Django API with PostGIS routing, subscription packages, and real-time trip tracking. Live at muri.sa.
01The Problem
Client,driver,admin—oneplatform.
MURI is a student transportation platform. On the surface it looks simple — a client books a trip, a driver accepts it, an admin oversees operations. The complexity is in the seams: three completely different user types, each needing their own experience, all sharing one backend that enforces the right rules without tangling the logic.
Each role gets a dedicated application: the Student App (Flutter) for clients, the Driver App (Flutter) for drivers, and the Admin Dashboard (Angular 19) for administrators. The Django backend mirrors this split — separate modules under UsersModules/ for client, driver, and administrator, each with its own serializers, views, and permission classes.
MURI added a further dimension: subscription packages. Clients don't pay per trip — they buy packages. This means trip availability is gated by subscription state, expiry, and remaining quota — all of which must be checked atomically to avoid race conditions when two requests arrive simultaneously.
And throughout, the system needs to answer geographic questions in real time: which drivers are within range? What regions are serviced? What is the estimated route? A plain latitude/longitude column in PostgreSQL cannot answer these efficiently at scale.
Core requirements
- Three role-specific apps (Student, Driver, Admin) backed by one modular Django API
- Isolated permission scopes for client, driver, and administrator user types
- Geospatial queries for driver availability and service region management
- Subscription-gated trip booking with atomic quota checks
- HyperPay payments, driver wallet accounting, and Firebase push notifications
- Full Arabic and English localization across all apps and the admin dashboard
02Architecture
Threeapps.OneAPI.Cleardataflow.
Each role gets its own frontend; the Django backend is organized into modular apps — one per role and concern — sharing a single PostGIS-enabled database with Celery for async work and Firebase for push delivery.
Student App (Flutter)
Client role — subscriptions, trips, live tracking, payments
Driver App (Flutter)
Driver role — trip acceptance, navigation, wallet & earnings
Admin Dashboard (Angular 19)
Administrator role — users, vehicles, regions, payments, ops
Django REST API
Modular UsersModules (client / driver / admin), JWT auth, DRF
PostGIS + PostgreSQL
Geospatial routing, region polygons, trip records
Redis + Celery
Task queue, caching, subscription sync, payment webhooks
Real-time Layer
Firebase push + WebSocket trip updates to client and driver apps
Payments + AWS S3 (async)
HyperPay gateway with webhook reconciliation, driver wallet accounting, and S3 media uploads — all off the request cycle via Celery
03Key Decisions
Whatwechoseandwhy.
PostGIS over plain lat/lng columns
Storing coordinates as two float columns works until you need to query 'find all drivers within 5km'. That query becomes a full table scan with manual Haversine math. PostGIS gives us native spatial indexes, proximity queries in a single SQL call, and region polygon support — all without a separate geospatial service.
One Django module per role — mirrored in the frontends
The backend splits client, driver, and administrator into distinct Django apps under UsersModules/, each with its own models, serializers, views, and permission classes. The frontends mirror this: Student App and Driver App (Flutter, clean architecture) and Admin Dashboard (Angular 19) each talk only to their role-scoped API surface.
WebSocket push for trip tracking
Polling every 2 seconds for driver location means 30 requests per minute per active trip. At modest scale this becomes expensive. WebSocket connections stay open for the duration of the trip, pushing location updates server-side. Driver location is updated via Celery → Redis → WebSocket channel group.
Atomic subscription quota checks with Redis locks
When two concurrent requests try to book the last trip in a subscription package, a naive check-then-decrement approach creates a race condition. Redis distributed locks ensure the quota check and decrement happen atomically — one request wins, the other gets a clear 'quota exhausted' response.
04Outcomes
Asystembuilttoscalewiththemarket.
User roles
Client · Driver · Admin — fully isolated permissions
Geospatial engine
Region-aware routing with native geo-query performance
Trip status update delivery
WebSocket push vs. polling
Languages supported
Full Arabic and English localization (i18n)
05Challenges & Lessons
Whatmadethishard.
Geospatial complexity
PostGIS documentation is dense. Getting spatial indexes right for proximity queries took iteration — the wrong index type caused slow queries under load that only surfaced in staging.
Real-time consistency
Keeping trip state in sync between client, driver, and admin views required careful ordering of WebSocket events. We built an event log to replay missed updates on reconnection.
Permission surface area
Multi-role systems have a large permission surface. We built a custom permission matrix tested with over 50 test cases covering edge conditions — an admin shouldn't be able to create a trip as a client.