Skip to content

Multi-tenancy & routing

Chapter 3. You've seen the services. Now the defining question: how does one shared system serve thousands of stores without ever mixing them up?

How one shared platform serves many stores, tells them apart safely, and routes each request — without ever letting one store read another. This is the heart of the "Shopify for India, but cheaper" idea.

The one mental shift

  • Old way: one app per store — a store is a separate server. Isolation is physical; cost is O(N).
  • Ratio-3.0: one shared app serves all stores; a store is just data keyed by a tenant_id. Isolation is enforced in code; cost is O(1) — adding a store means inserting rows, not starting a server.

So every request must answer two questions a separate server used to answer for free:

  1. Which tenant is this for?resolution
  2. Which page of that tenant do I render?routing

End-to-end: from hostname to page

Resolution — how a request finds its tenant (security-critical)

A shopper hits a hostname, of two kinds:

  • Platform subdomain — every store gets one automatically (e.g. acme.ratiodev.in). Covered by one wildcard TLS cert, so it works instantly at onboarding.
  • Custom domain — optional (e.g. shop.acme.com). Needs its own cert (see custom domains).

The steps:

  1. The request hits the edge.
  2. The edge looks up host → tenantId in Workers KV (TENANTS), falling back to the DB only on a miss — and only matching a verified domain.
  3. The edge forwards to the origin over a private path and injects a trusted header x-ratio-tenant.
  4. The origin trusts only that header — it never reads the public Host to decide the tenant.

The rule to burn in

tenantId is never derived from anything a shopper can set — not the Host at the origin, not a query param, not a cookie. The network boundary (a private origin that fails closed without x-edge-auth) is what makes the injected header trustworthy.

Routing — how it then finds the page

The origin now knows tenantId + a path. There are no per-store file routes — routing is data-driven. First match wins:

The point: a merchant can publish a brand-new route just by adding data — no code, no rebuild. Reserved paths always win, so a merchant page can never shadow /cart or /api/*.

Isolation — how stores never leak into each other

One shared app; a store is data keyed by tenant_id. Isolation is enforced in code, not by separate servers:

LayerHow it works
StorageOne shared Postgres; every row keyed tenant_id; S3/KV keys namespaced per store/host
The one gateA tenant-scoped repository (forTenant(id)) — every read/write injects tenantId; a query with no tenantId can't be expressed → it throws, never a global scan
ProofA blocking CI test: "tenant A can never return tenant B" — fails the build if it regresses
RoutingOnly verified = true host→store claims count; an unverified squat never serves
ComputeStateless origin — any replica serves any store; the store comes only from the trusted edge header
SecretsRows store a secretRef; the real credential is pulled into memory only for the call, never saved or logged
FairnessPer-tenant + per-/assistant rate limits (in Postgres), plus a per-request render budget
Edge authThe origin is private; it needs x-edge-auth and refuses if unset

Tenant lifecycle

  • Onboard: insert the tenant row (pending) → seed the base theme + default pages → assign a platform subdomain → active. Seconds; no repo/build/deploy.
  • Suspend: serve a "store paused" page; keep the data. (Suspension is enforced at the origin, not by deleting the KV route.)
  • Delete: purge cache → delete rows/blobs/secret → tombstone. Must be provably complete (India-DPDP hard-delete).

Status today: read, but not written

The origin serves only status = 'active' and 404s anything else, but no code path currently writes suspended / pending / deleting — the enforcement is in place; those transitions are done out-of-band for now. Deletion is a hard delete with a proof, not a deleting state.