Technical architecture · v0.1

Built by one person, on purpose.

Every choice below optimises for the same constraint: a single developer must be able to hold the whole system in their head, ship daily, and not be woken at 2am. That rules out most of what a larger team would reach for — and rules in a few things that look almost too simple.

Three flows carry the product. They're diagrammed and steppable below.

The stack

Five layers, one language

TypeScript from the database types to the mobile screen. One language means a refactor crosses the whole system in a single pass, and AI assistance doesn't have to translate across a boundary. Click any layer to open it.

Why this and not microservices

There is one developer. A distributed system turns every bug into a distributed debugging session, every deploy into a coordination problem, and every incident into a question of which service. The whole platform is a monolith behind a CDN until there is a team to justify splitting it — and at that point the seams are already drawn along packages/.

System map

What talks to what

Everything in Mumbai (ap-south-1 / bom1). A US-East default would add 250ms+ to every query from Kochi, and each screen makes several.

Clients
Customer app
Expo SDK 55 · iOS + Android
Customer web
Next.js · SEO locality pages
Provider PWA
Next.js · web push + WhatsApp
Admin
route group inside web
▼ ▼ ▼
Application — Vercel, region bom1
Hono + tRPC API
all writes · business rules · idempotency
packages/core
pure logic · zod · state machines
Next.js SSG/ISR
locality pages for search
▼ ▼ ▼
Data — Supabase, ap-south-1
Postgres + PostGIS
source of truth · geo search
RLS
reads only, never business rules
Realtime
booking status → clients
Storage / R2
job photos, parts bills
▼ ▼ ▼
Async spine
outbox table
written in the same txn as state
Outbox worker
FOR UPDATE SKIP LOCKED · backoff
QStash
delayed callbacks — dispatch timeouts
pg_cron
60s safety sweep
▼ ▼ ▼
External
WhatsApp BSP
AiSensy / Interakt — primary channel
Exotel
masked calling
DigiLocker
provider identity, via AuthBridge
Expo Push
secondary channel
Razorpay Route
Phase 3 only
Data flow

The three flows that carry the product

Everything else is CRUD. These three are where a solo build quietly fails, so each is diagrammed step by step. Use the arrows, or press Play.

Entities

The data model

Twenty tables. Four of them — the green group — are the reason this product can make the promises it makes.

The one rule that matters

bookings has no price column. Prices are never updated — a change request writes a new estimate version pointing back at the old one. The current price is a query: the agreement on the highest agreed estimate version. The moment a price becomes mutable, every guarantee in the product becomes a claim you can't back up.

Communication

Who talks to whom, and how

The governing rule: row-level security for reads, your own API for every write. RLS is excellent at "can this user see this row" and unmaintainable at "can this provider accept this booking given the round is open, he's in the service area, and nobody else has accepted."

FromToMechanismModeWhy this way
Any clientPostgresSupabase JS, RLS-scopedsync readNo API layer to maintain for reads; realtime subscriptions come free.
Any clientAPItRPC over HTTPS + Idempotency-Keysync writeEvery business rule lives in one testable place. Idempotency because Indian networks drop requests mid-flight.
APIPostgresDrizzle, single transactionsyncState change + audit event + outbox rows commit together or not at all.
PostgresClientsSupabase Realtime on bookingspushLive booking status without building a websocket layer.
APIQStashHTTPS, delayed deliveryscheduledThe 45-second dispatch timeout, without Redis or an always-on worker.
QStashAPIPOST /internal/dispatch/expirecallbackHandler must be idempotent — duplicate delivery has to be harmless.
WorkerPostgresFOR UPDATE SKIP LOCKEDasync pollPostgres as the queue. Safe for concurrent workers, ~40 lines.
WorkerWhatsApp BSPHTTPS, retry + backoffasyncPrimary notification channel. Failures retry rather than vanish.
Provider phoneCustomer phoneExotel masked bridgevoiceNeither party ever sees the other's number. Privacy and anti-leakage in one line item.
pg_cronPostgresFunction, every 60ssweepCatches expired rounds and stuck outbox rows if a webhook is ever dropped.

Idempotency is not optional here

Every mutating endpoint takes an Idempotency-Key, stored with a unique index on (key, user_id). A replay returns the stored response rather than acting twice. Without it, a double-tapped Accept creates two accepts and a double-tapped Agree creates two agreements — in the money ledger.

Location is deliberately thin

Provider position is pinged every 20–30 seconds, foreground only, and only between accepted and arrived. The latest point overwrites the last; no history is kept. Battery life, user trust and DPDP purpose-limitation all point the same way.

Queue system

Postgres is the queue

Two async mechanisms carry everything. Neither needs Redis, BullMQ, Kafka, or a machine that has to stay up.

Mechanism 1 · delayed work

QStash callbacks

When a dispatch round opens, the API schedules an HTTP callback for 45 seconds later. If nobody accepted by then, that callback closes the round and opens a broadcast round. No polling loop, no process to keep alive.

// when the round opens
await qstash.publishJSON({
  url: '/internal/dispatch/expire',
  body: { roundId },
  delay: 45 // seconds
})
Mechanism 2 · guaranteed delivery

The outbox table

Notification intent is written inside the same transaction as the state change. A worker drains it with row-level locks that let several workers run safely without coordination.

SELECT * FROM outbox
 WHERE status = 'pending'
   AND next_attempt_at <= now()
 ORDER BY created_at
 FOR UPDATE SKIP LOCKED
 LIMIT 50;
Correctness

The accept race is settled by a single SELECT booking FOR UPDATE. Two simultaneous accepts cannot both win — the second reads a row that is no longer matching.

Resilience

Every timeout handler is idempotent, and a pg_cron sweep runs every 60 seconds. A QStash outage degrades latency, never correctness.

When to revisit

Add a real broker when a single Postgres instance can't keep up with outbox drain — realistically past 50,000 bookings a month. Not before.

Environments

Deploy, migrations, testing

Environments

Three, no more

local (Supabase CLI + Docker) → preview (Vercel preview + a Supabase branch per PR) → production. A staging environment nobody maintains is worse than none.

Migrations

Drizzle Kit, in git

Applied by CI, never by hand. Never click-edit the production schema in the dashboard — that's how a solo project loses the ability to reproduce its own database.

CI

Under three minutes

Typecheck, lint, packages/core unit tests, migration dry-run. Any longer and you'll start skipping it, which makes it worthless.

Mobile

EAS Build + Update

JavaScript fixes ship over the air in ~10 minutes. Only native changes need a store round-trip — which matters enormously in the first months.

Testing

Narrow and deep

Near-total coverage on packages/core (pure, milliseconds). Integration tests on exactly three flows: dispatch race, the agreement chain, outbox drain. One Playwright end-to-end. No coverage gate.

Observability

Reconstructable

Sentry for errors, PostHog for behaviour and session replay. But the real tool is booking_event — any booking can be replayed from the database, which beats distributed tracing at this size.

The one test to write first

Two concurrent accepts on the same booking must produce exactly one winner. Write it in week seven, run it in CI forever. Everything else about dispatch can be fixed later; this one silently corrupts real jobs and real trust.

Deliberate omissions

What we're not using, and why

For a product owner, this list is often more informative than the stack itself — each line is a cost avoided.

✗ Microservices

One developer. A distributed system is a distributed debugging session.

✗ Redis + BullMQ

Postgres plus delayed callbacks does the job. Add a broker when you can name the query it fixes.

✗ GraphQL

tRPC gives end-to-end types with no schema layer, no resolvers, and no N+1 class of bug.

✗ Firebase / Firestore

Wrong data model for an append-only money ledger with relational integrity and geo queries.

✗ Kubernetes

Nothing here needs it. Managed services and, at most, one small machine.

✗ Shared web/native UI

React Native Web and Solito cost more than they save at this size. Share tokens, not components.

✗ A map SDK in v1

The customer picks a building, not a pin. The provider needs directions — a deep link into the nav app he already trusts. Saves an SDK, a key, a billing account and about a week.

✗ Native provider app in v1

~95% of providers are on Android, where PWA web push works. Skips an entire store cycle, and never requires an install to receive work.

✗ Online payments in v1

Adds 3–4 weeks plus a compliance surface. Cash works at launch; the ledger is built to accept payments the day you want them.