One UUID, Three Services: Killing the Mapping Layer
Most multi-service systems drown in id-mapping tables. Here is how NemoRouter flows a single organization UUID unchanged through Supabase, LiteLLM, and the backend — no mapping columns, no sync jobs.
The quiet tax in most multi-service architectures is the mapping layer: a table that says "org A here is org-xyz there is tenant-123 in the third place." Every such mapping is a row that can go stale, a join that can miss, and a sync job that can fall behind. NemoRouter doesn't have one — a single organization UUID flows unchanged through every service. This post is why that's possible and what it buys.
Where mapping layers come from
Mapping tables appear when two systems each mint their own ids for the same entity. Your app calls an organization org_A; the billing system calls it cust_42; the inference layer calls it tenant_9. Now you need a table to translate between them, and you need it everywhere the entity crosses a boundary:
app.org_id ──┐
├─► id_mapping (org_id ↔ billing_id ↔ tenant_id) ← the tax
billing.id ──┘That mapping table is load-bearing and fragile. A failed sync, a partial migration, a race on creation, and suddenly the same real organization is two different things to two services. Every cross-service feature has to join through the map, and every join is a place the map can be wrong.
The alternative: agree on one id
NemoRouter's tenancy rule is a single sentence: organization_id is one UUID, and it carries the exact same value everywhere. When an org is created, it gets a UUID, and that UUID is its id in every system:
Organization UUID: 550e8400-e29b-41d4-a716-446655440000
├─ Supabase (nemo schema) organization_id = 550e8400…
├─ LiteLLM (public schema) organization_id = 550e8400…
└─ Nemo Backend (in-path) org_id = 550e8400… (same value)There is no litellm_org_id, no mapping column, no translation table. org_id (the internal Python shorthand) and organization_id (the boundary name) are names for the same UUID, never different values. The same discipline extends one level down to team_id (org → team → member), so teams need no mapping either.
A naming convention, not a mapping
org_id, organization_id, and the frontend's orgId are three spellings of one value. The rule is: spellings may differ by context (DB column, JSON field, Python param), the UUID never does. The moment two of them could hold different values, you're back to a mapping layer — so they can't.
How is this even possible?
Two design choices make same-UUID viable where it usually isn't:
- One database. Supabase's
nemoschema and LiteLLM'spublicschema live in the same Postgres instance. So an authorization check can join NemoRouter's feature tables against LiteLLM'sLiteLLM_OrganizationMembershipdirectly — same UUIDs, same database, no cross-service API call. (This is what powers the RLS multi-tenancy model.) - No BYOK, no second identity. Because we manage all provider keys, an organization has exactly one identity in our world. There's no external account to reconcile against, so there's nothing to map.
The one translation point
Honesty about the exception: there is exactly one place a translation happens, and it's a naming quirk, not a value mapping. LiteLLM's Pydantic model exposes the field as org_id while its Prisma database column is organization_id — same value, two names. Our auth boundary reads row["organization_id"] and assigns it to AuthContext.org_id. That's the single, documented seam. It maps a name to a name; the UUID is untouched. We never introduced a column that maps one UUID to a different UUID, because that's the thing that rots.
What it buys you
| Without mapping layer | What you avoid |
|---|---|
No id_mapping table | A whole class of "the orgs disagree" bugs |
| No sync job | A background process that can fall behind |
| No translation on every boundary | Join complexity + latency at each hop |
| Authorization is a direct join | Cross-service auth calls |
The deeper win is conceptual: there is one organization, with one id, and every service is talking about the same thing by definition. New features don't have to learn the mapping because there is none. "Same UUID everywhere" is less code and fewer bugs, which is the only kind of architecture decision worth making twice.
The takeaway
Mapping layers are a tax you pay for letting each system mint its own ids. Refuse that at the start — one UUID per org, carried unchanged through every service, backed by a single shared database — and the tax disappears: no mapping table, no sync job, no "which id is this" bug. The id is the entity, everywhere. That's the foundation the multi-tenancy and billing models are built on.