ADR 008: Slug-Based Multitenancy on Partner Entity¶
Date: 2025-12-12 Status: Accepted (currently single-tenant in production)
Note: Retrospective ADR written 2026-04-26. The columns landed during partner-service hardening (6fea978, 2025-12-12) and have remained the multitenancy primitive since. The decision was made implicitly; this ADR captures the rationale ex-post.
Context¶
Portugal Odyssey is operated by a single client company (Cristina Meireles) but the architecture has, from day one, anticipated the platform being licensed or sold as a white-label to additional tourism organizations. The pragmatic question at MVP time: do we need tenant isolation now, and if so, at what level?
Three options were considered:
- Database-per-tenant. Maximum isolation; one Postgres database per tenant. Painful to operate at the platform's single-VPS scale and overkill for a foreseeable future of 1–5 tenants.
- Schema-per-tenant. One
public_*schema per tenant inside a shared Postgres. Less operational cost than separate databases, but every service must construct schema names dynamically, and migrations multiply per-tenant. - Row-level scoping via a tenant column. Every tenant-scoped row carries a
tenant_slug(ortenant_id). Queries filter by it; isolation is enforced at the application layer (and optionally Postgres RLS later).
The platform is not yet multi-tenant in production — partners.tenant_slug is populated for every partner but maps to a single platform tenant. The decision was to make the schema multi-tenant-shaped without paying the operational cost of true isolation until a second tenant materializes.
Decision¶
Adopt row-level multitenancy with two parallel keys on the Partner entity:
| Column | Type | Source | Audience |
|---|---|---|---|
tenant_slug |
TEXT (UNIQUE WHERE NOT NULL) | Human-set, lower-cased / kebabish | URLs, logs, glossary, human discovery |
iam_tenant_id |
UUID | FK-shaped reference into iam.tenants(id) |
Tokens, joins, foreign-key integrity |
tenant_slug is the canonical handle referenced across documentation, the glossary, and the project overview. iam_tenant_id is the stable identifier the IAM schema uses internally (iam.tenants(id), iam.user_tenants, iam.user_roles.tenant_id).
Why both?¶
The slug is human-readable and stable in URLs (https://partner.<tenant>.portugalodyssey.pt is on the future roadmap). The UUID is the right primary-key shape for IAM joins and JWT claims. Keeping both means:
- a tenant can rename its slug without breaking IAM joins,
- IAM tokens carry the UUID (
tenant_idclaim →x-tenant-idheader inservices/auth-service/src/shared/express-jwt.ts), - non-IAM tables index on
tenant_slugfor human-friendly query plans.
JWT propagation¶
Auth-service's expressJwt middleware verifies the Keycloak-issued JWT, extracts tenant_id (UUID) and tenant_ids[] (the multi-tenant case), and forwards them as x-tenant-id and x-roles headers to downstream services via the api-gateway. The TenantGuard (services/auth-service/src/shared/nest/tenant.guard.ts) checks the requested x-tenant-id against the user's allowed list.
Why slug-based and not UUID-only¶
Pure-UUID multitenancy works, but everywhere a human reads or types a tenant identifier (URL paths, log lines, support tickets, the glossary, partner-onboarding scripts) UUIDs are friction. Slugs cost one extra column and a UNIQUE constraint to keep them disambiguated. The trade was judged worth it.
Backfill¶
A backfill in partner-service's database.service.ts derives tenant_slug from short_name for partners that pre-date the column:
UPDATE partners
SET tenant_slug = COALESCE(tenant_slug, short_name)
WHERE tenant_slug IS NULL AND short_name IS NOT NULL;
short_name was a pre-existing 5-char uppercase code; using it as the seed slug avoided generating placeholder values.
Consequences¶
Positive¶
- One physical database, N logical tenants. Operational footprint stays tiny; no per-tenant migration multiplication.
- Slug in URLs and logs. Future tenant onboarding can publish at
partner.<slug>.portugalodyssey.ptwithout coordinating UUID-to-name mappings. - IAM is FK-correct.
iam_tenant_idis the joinable identifier; renaming a slug doesn't cascade through token-issued claims. - JWT-driven scoping. Downstream services receive
x-tenant-idfrom the gateway and don't need to re-resolve tenancy from the request body. - Cheap to defer real isolation. When a second tenant arrives, the codebase already filters by
tenant_slug; tightening to RLS or schema-per-tenant is incremental, not a rewrite.
Negative¶
- Two keys for one concept. New developers ask "is the tenant the slug or the UUID?" The answer is "both, depending on which boundary you're at." Glossary entry mitigates but doesn't eliminate the confusion.
- No row-level enforcement yet. Application code is responsible for adding
WHERE tenant_slug = $1to every relevant query. A missing predicate leaks data across tenants. Postgres RLS would catch this; not adopted yet because the production tenant count is 1. - Slug uniqueness scope is global. The
UNIQUEindex onpartners.tenant_slugmakes tenant slugs globally unique — fine for white-label tenants but constrains naming if "tenant" semantics ever expand. short_namebackfill assumption. The slug-from-short_name backfill collides if two partners had the sameshort_name(the field isVARCHAR(5)and not historically unique). Pre-launch state means this hasn't bitten, but the backfill is one-shot and idempotent only because the production set is small.- Cross-service propagation discipline. Most non-partner services don't carry their own
tenant_slugcolumns yet; they rely on Partner-derived joins and the gateway'sx-tenant-idheader. Adding RLS would require backfilling tenant columns on calendar/booking/contract/experience entities.
Open follow-ups¶
- Decide whether to add
tenant_slugcolumns to calendar / booking / experience entities for direct row-level filtering, or keep deriving via partner joins. - When (and if) Postgres RLS gets adopted, decide whether the policy keys on
iam_tenant_id(UUID, stable) ortenant_slug(text, human-friendly). - The glossary entry describes the intent; an explanatory doc (Diataxis "explanation") on the multitenancy surface would help when a second tenant lands.
References¶
services/partner-service/src/common/database/database.service.ts—partners.tenant_slug+iam_tenant_idcolumns + backfillservices/partner-service/src/modules/partners/entities/partner.entity.ts— TS shapeinfrastructure/migrations/iam/00001_initial_schema.sql—iam.tenants(id, slug)+iam.user_tenantsservices/auth-service/src/shared/express-jwt.ts— JWT →x-tenant-idheader propagationservices/auth-service/src/shared/nest/tenant.guard.ts— guard checking requested vs allowed tenantsdocs/reference/glossary.md§"Tenant Slug" — terminology referencedocs/project-overview.md§"Partner" — domain model