Cum construiesti un SaaS multi-tenant cu Prisma si Postgres
Arhitectura, RLS, Prisma middleware, migrari si costuri reale pentru un SaaS multi-tenant pe Postgres. Ghid tehnic pentru fondatori si seniori.

Daca nimeresti gresit modelul de multi-tenancy la inceput, o sa platesti ulterior cu migrari dureroase, blocaje de crestere si facturi cloud care te dor. In SaaS, izolarea datelor si performanta nu se pot improviza dupa lansare.
Raspunsul scurt: pe Postgres, pentru 90% din SaaS B2B, un singur cluster + un singur schema, coloana tenant_id pe fiecare tabela sensibila si Row Level Security (RLS) activat este solutia echilibrata. Prisma ofera tipare clare pentru acest model, iar cu un strat de context care seteaza tenant_id pe sesiune poti avea siguranta si viteza. Cand ai clienti enterprise cu cerinte stricte, treci la schema-per-tenant sau db-per-tenant pentru acei clienti, nu pentru toti.
Modele de multi-tenancy: ce alegi si de ce
Alegerea modelului te blocheaza sau te accelereaza. Mai jos sunt cele patru optiuni comune si cand au sens.
| Model | Izolare | Complexitate | Cost | Migrari | Reporting global | Cand il alegi |
|---|---|---|---|---|---|---|
Single DB, single schema, coloana tenant_id + RLS | Medie (buna cu RLS) | Redusa | Cea mai mica | Simple | Usoara | MVP -> mid-scale, 10-10k clienti |
Single DB, partitii pe tenant_id | Medie (buna cu RLS) | Medie | Redusa | Moderate | Usoara | Cand ai tabele mari cu write/read intens |
| Schema-per-tenant (aceeasi baza) | Ridicata | Medie-ridicata | Medie | Grele (coordonare) | Dificila | Clienti enterprise care cer separare |
| Baza-per-tenant (cluster sau instanta separata) | Foarte ridicata | Ridicata | Mare | Grele | Foarte dificila | Reglementari stricte, noisy neighbor sever |
In practica, majoritatea SaaS-urilor incep cu un singur schema + tenant_id. RLS din Postgres ofera izolarea la nivel de rand, iar Prisma mapeaza usor campul in modele. Schema-per-tenant devine util cand ai 2-10 clienti enterprise cu nevoi distincte (retentie de date, ferestre de mentenanta), dar nu vrei sa complici calea comuna.
Un sfat pragmatic: optimizeaza pentru migrare. Daca incepem cu tenant_id + RLS, mai tarziu putem extrage cativa clienti critici in schema separata sau chiar in baza separata, folosind replicare logica si migrare incrementala.
Arhitectura recomandata (pragmatic, scalabila)
Componente propuse:
- Frontend: Next.js sau React + Vite; route-based code splitting.
- Backend: Node.js (NestJS sau Next.js Route Handlers), Prisma ORM.
- Baza de date: Postgres 15+ (AWS RDS/Aurora, GCP Cloud SQL, sau un cluster dedicat pe bare metal daca vrei predictibilitate).
- Cache: Redis (session scurta si rate limit), optional Q pentru joburi (BullMQ).
- Observabilitate: OpenTelemetry + Grafana Tempo/Loki/Prometheus, Sentry pentru erori.
- Edge si securitate: Cloudflare pentru WAF, rate limiting si cache static.
Fluxul de request:
- Rezolva tenant-ul din subdomeniu sau header (
x-tenant), cu fallback la tenant implicit pentru login. - Valideaza user-ul (JWT sau sesiune). Mapare user -> lista de tenant_id permise.
- Seteaza context de tenant pe conexiunea Postgres (RLS) si pe logger/traces.
- Query-urile Prisma ruleaza sub politica RLS, returnand doar randurile din tenant.
- Pentru rapoarte globale, foloseste un rol cu permisiuni explicite sau joburi ETL catre un data mart separat.
Diagrama simplificata a fluxului
- Client -> Edge (Cloudflare) -> API Gateway -> Service API (Node + Prisma) -> Postgres (RLS) -> Redis (cache selectiv) -> Observabilitate.
Un mic detaliu care conteaza: cand folosesti pgbouncer in modul de session pooling si te bazezi pe SET-uri de sesiune (pentru RLS), trebuie sa le setezi pe fiecare tranzactie sau sa configurezi server_reset_query. Altfel, variabila de tenant ramane "lipita" de alt client. Nu vrei asta.
Implementare cu Prisma si Postgres (RLS corect)
1) Schema Prisma cu tenantId
// schema.prisma
generator client {
provider = "prisma-client-js"
}
datasource db {
provider = "postgresql"
url = env("DATABASE_URL")
}
model Tenant {
id String @id @default(uuid())
name String
createdAt DateTime @default(now())
users User[]
projects Project[]
}
model User {
id String @id @default(uuid())
email String @unique
password String
tenantId String
tenant Tenant @relation(fields: [tenantId], references: [id])
role String @default("member")
createdAt DateTime @default(now())
@@index([tenantId])
}
model Project {
id String @id @default(uuid())
tenantId String
tenant Tenant @relation(fields: [tenantId], references: [id])
name String
status String @default("active")
createdAt DateTime @default(now())
@@index([tenantId])
}
Cheia: fiecare entitate care apartine unui tenant are tenantId si index pe acea coloana. Foloseste uuid pentru a evita coliziuni si leak-uri prin incrementale.
2) Activarea RLS si politici
In Postgres, cream un namespace logic app.current_tenant_id si politici RLS pe tabele. Facem asta printr-o migratie SQL.
-- migrations/2024050101_rls.sql
-- Extindem search_path cu un pseudo-namespac e pentru a clarifica intentia
-- (folosim set_config pentru a citi/ scrie valoarea curenta de tenant).
ALTER TABLE "User" ENABLE ROW LEVEL SECURITY;
ALTER TABLE "Project" ENABLE ROW LEVEL SECURITY;
-- Politici RLS: un rand poate fi accesat doar cand tenantId == current_setting('app.current_tenant_id')
CREATE POLICY user_isolation ON "User"
USING (tenantId = current_setting('app.current_tenant_id')::uuid)
WITH CHECK (tenantId = current_setting('app.current_tenant_id')::uuid);
CREATE POLICY project_isolation ON "Project"
USING (tenantId = current_setting('app.current_tenant_id')::uuid)
WITH CHECK (tenantId = current_setting('app.current_tenant_id')::uuid);
-- Optional: un rol special pentru rapoarte globale
CREATE ROLE reporter NOLOGIN;
GRANT SELECT ON "User", "Project" TO reporter;
Folosim current_setting('app.current_tenant_id') ca sursa a adevarului. Avantaj: nu poluam fiecare query cu WHERE; Postgres aplica politicile automat.
3) Setarea tenant-ului pe sesiune (Prisma middleware)
In cod, la fiecare request autenticat, setam variabila de sesiune.
// tenancy.ts
import { PrismaClient } from '@prisma/client'
const prisma = new PrismaClient()
export async function withTenant<T>(tenantId: string, fn: (db: PrismaClient) => Promise<T>) {
// Folosim o tranzactie pentru a garanta ca SET este local si curat la commit
return await prisma.$transaction(async (tx) => {
await tx.$executeRawUnsafe(
`SET LOCAL app.current_tenant_id = $1`, tenantId
)
return await fn(tx)
})
}
// exemplu de utilizare in Next.js Route Handler
export async function POST(req: Request) {
const { subdomain, user } = await resolveAuth(req)
const tenantId = await mapUserToTenant(user, subdomain) // validare ca user apartine tenantului
const project = await withTenant(tenantId, (db) => {
return db.project.create({ data: { name: 'Alpha', tenantId } })
})
return new Response(JSON.stringify(project), { status: 201 })
}
Important: SET LOCAL in interiorul unei tranzactii evita scurgerile de context cand folosesti pgbouncer. Daca setezi la nivel de sesiune simplu (SET), poti contamina o alta cerere alocata aceleiasi conexiuni din pool.
4) Validarea datelor la scriere
RLS acopera SELECT/UPDATE/DELETE, insa e sanatos sa validezi si la nivel de aplicatie ca tenantId din payload se potriveste cu contextul curent. In exemplul de mai sus, forsam tenantId la creare, nu il luam din input-ul clientului.
5) Seeder si migrari sigure
- Seeder: creeaza intai tenant-ul, apoi userii si datele aferente; nu crea entitati orfane fara
tenantId. - Migrari: foloseste
prisma migratepentru schema, plus migrari SQL pentru politici RLS. Versioneaza atent politicile; o politica gresita = outage de date.
# generare migrari Prisma
npx prisma migrate dev --name add-tenant-ids
# rulare migrari in CI/CD pe staging -> productie
Un mic adevar cinic: politica RLS scrisa gresit e ca un firewall inchis pe dinauntru. Totul pare ok pana cand nimic nu mai merge.
Partitionare, indici si performanta
Pe masura ce cresc tabelele, tenant_id devine coloana calda. Cateva tactici:
- Indici compusi:
(tenantId, createdAt)pentru liste paginate recente. - Partial indexes: daca ai multe statusuri,
CREATE INDEX ... WHERE status = 'active'reduce dimensiunea. - Partitionare: pentru tabele gigant (loguri, evenimente), foloseste
PARTITION BY HASH (tenantId)sau pe interval de timp (createdAt). Cu Prisma, tratezi tabelele la fel; Postgres decide partitia. - Connection pool: pgbouncer in transaction pooling +
SET LOCALin tranzactii. - Caching: cache pe chei care includ tenant (
tenant:{id}:project:{id}). Evita cache global care amesteca tenantii.
Pentru rapoarte cross-tenant, evita sa le rulezi live pe baza principala. ETL periodic catre un data mart (ex: Postgres separat sau DuckDB/BigQuery) te scuteste de locks si latente.
Alternative: schema-per-tenant fara durere prea mare
Daca un client cere izolare puternica si audit separat, schema-per-tenant este un pas bun. Pattern:
- Crezi un nou schema
t_<tenantShortId>cu acelasi set de tabele. - Rulezi migrarile pentru fiecare schema (automatizate in CI/CD).
- La runtime setezi
SET LOCAL search_path = t_123, publicinainte de queries.
Limitari Prisma: declarativ, Prisma mapeaza un singur schema (de obicei public). Trucul este sa definesti tabelele tot in public si sa folosesti search_path pentru a rezolva catre schema specific. Totusi, asta necesita disciplina pe nume si pe migrari; iar cu pgbouncer trebuie sa folosesti iar SET LOCAL in tranzactie. Pe scurt: functioneaza, dar creste complexitatea operationala.
Observabilitate si securitate
- Tracing: propaga
tenant_idin fiecare span (OpenTelemetry). In loguri,tenant_idte ajuta sa filtrezi incidentele. - Rate limiting per tenant: nu lasa un client sa satureze pool-ul de conexiuni.
- Backups si restore selective: e vital sa poti face restore doar pentru un tenant (ROW FILTER restore sau export incremental). O strategie este sa mentii un audit log cu
tenant_idsi sa repopulezi selectiv. - Criptare: transport TLS si criptare at-rest (RDS are by default), plus
pgcryptopentru coloane sensibile. - Secret management: AWS Secrets Manager/ GCP Secret Manager.
Daca planifici feature-uri AI in SaaS, stabileste de la inceput granitele de date pe tenant si ce se logheaza catre providerii de modele. Am detaliat optiunile de modele in evaluarea LLM-urilor pentru productie SaaS.
Testare si QA pentru izolare
- Unit + integration: mock pentru
withTenant()si teste care verifica ca un user din tenant A nu vede datele din B. - Teste RLS: ruleaza queries direct pe Postgres cu
SET LOCAL app.current_tenant_idpentru a verifica politicile. - Contracte API: schema validation (zod/yup) care elimina
tenantIddin input si il injecteaza din context. - Load testing: Gatling/K6 cu etichete per tenant ca sa surprinzi „noisy neighbor”.
Ce se strica in productie
- pgbouncer si variabile de sesiune: daca folosesti
SETla nivel de sesiune, contextul se poate scurge intre clienti. Solutie:SET LOCALin interiorul tranzactiei sau foloseste Prisma Data Proxy (care gestioneaza conexiunile la nivel de cerere). - Politici RLS uitate: o tabela noua fara RLS activat expune date cross-tenant. Solutie: politica de CI care refuza migrari fara
ALTER TABLE ... ENABLE ROW LEVEL SECURITYsi o politica implicita. - Query-uri „admin” in acelasi pool: un job de raportare fara RLS poate scana toata baza si consuma I/O. Solutie: rol separat + replica de citire sau baza separata pentru analytics.
- Migrations locking: migrari lente pe tabele mari blocheaza scrieri. Solutie: migrari in pasi mici, indecsi „CONCURRENTLY”, ferestre de mentenanta per tenant la schema-per-tenant.
- Greseli de caching: chei lipsa
tenant_id-> leak de date. Automatizeaza un linter/grep pentru stringurile de cheie. - Scurgeri in logs: PII in event-uri cross-tenant. Normalizeaza si mascheaza inainte de a trimite catre log storage.
Costuri, timeline si ROI
Nu exista magie, exista bugete si trade-off-uri.
- MVP multi-tenant (single schema + RLS, billing Stripe, audit minim): 8-12 saptamani, 25k-60k EUR, in functie de suprafata de produs si integrare (auth, e-mail, facturare, onboarding).
- Hardening RLS, observabilitate, rate limiting si backup-restore per tenant: +2-4 saptamani.
- Schema-per-tenant pentru 1-5 clienti enterprise: +3-6 saptamani pentru automatizarea migrarilor, backup per schema, scripturi
search_pathsi runbooks. - Baza-per-tenant: proiect in sine; costuri cloud cresc cu 2-5x per client (compute, storage, operare), dar uneori este cerinta contractuala.
ROI vine din:
- Viteza de dezvoltare: Prisma + Postgres reduc timpul de livrare si numarul de buguri de date.
- Securitate implicita: RLS taie clasa intreaga de erori de filtrare.
- Operare simplificata: un singur cluster pana cand ai nevoie reala de mai mult.
Cand merita sa investesti in complexitate? Cand ai venituri pe client suficient de mari (contracte enterprise) sau cerinte legale. Altfel, ramai pe modelul simplu si optimizeaza performanta.
Exemplu end-to-end: creare proiect si listare sigura
// projects.service.ts
import { withTenant } from './tenancy'
import { prisma } from './prisma'
export async function createProject(ctx: { tenantId: string; userId: string }, input: { name: string }) {
return withTenant(ctx.tenantId, (db) => {
return db.project.create({
data: {
name: input.name,
tenantId: ctx.tenantId
}
})
})
}
export async function listProjects(ctx: { tenantId: string; userId: string }) {
return withTenant(ctx.tenantId, (db) => {
return db.project.findMany({
orderBy: { createdAt: 'desc' },
take: 20
})
})
}
Observa ca nu mai trecem where: { tenantId: ... }. RLS garanteaza izolarea. Totusi, pentru performanta, mentinem index (tenantId, createdAt).
Mentenanta si operare
- Rotatie chei si parole DB: trimestrial; foloseste IAM database authentication acolo unde e disponibil.
- Limite pe conexiuni:
max_connectionsrealistic (RDS default e adesea prea mic pentru multe microservicii). Foloseste pool-ul cu grija. - Runbooks: proceduri pentru „client X cere restore la un anumit timestamp”, „migrare schema urgenta”, „RLS policy fix”.
- Feature flags per tenant: ruleaza rollout controlat.
Un adevar pe care il inveti dupa primul incident: nu ai un „multi-tenant” cu adevarat pana cand nu ai si playbook-uri multi-tenant.
FAQ
Pot folosi Prisma cu RLS fara probleme?
Da. Prisma ruleaza SQL-ul sub politicile Postgres. Asigura-te ca setezi SET LOCAL app.current_tenant_id in interiorul fiecarei tranzactii care executa queries. Testeaza ca politicile sunt aplicate pe toate tabelele relevante.
Cand are sens schema-per-tenant in loc de tenant_id?
Cand ai clienti care cer izolarea fizica a obiectelor, ferestre de mentenanta pe client, versiuni personalizate sau cand volumul de date per client este foarte mare si vrei sa faci vacuum/analiza/mentenanta pe schema izolata.
Pot folosi pgbouncer cu acest model?
Da, dar seteaza variabilele de sesiune cu SET LOCAL in tranzactii. In mod ideal, foloseste transaction pooling. Evita reliance pe state de sesiune persistente.
Cum fac rapoarte cross-tenant fara sa incetinesc productia?
Extrage date periodic (ETL) intr-o replica sau data mart separat. Rulezi rapoarte grele acolo. In productie, mentine RLS strict si queries limitate per tenant.
Ce se intampla la migrari cand am schema-per-tenant?
Automatizeaza: mentine un registry de scheme si ruleaza migrarile pentru fiecare, cu lock si retry. Evita schimbari care necesita full table rewrite in ore de varf. Testeaza pe un mediu clonat.
Cum previn leak-uri prin cache?
Include mereu tenant_id in cheile de cache si invalideaza pe aceeasi dimensiune. Evita cache global pentru endpointuri multi-tenant.
Concluzii cheie
- Modelul
tenant_id+ RLS pe Postgres acopera majoritatea SaaS-urilor si ramane migra bil spre modele mai izolate. SET LOCALin tranzactie este esential cand folosesti pooling; altfel, risti scurgeri de context intre clienti.- Indicii pe
(tenantId, createdAt)si partitionarea pe tabele voluminoase mentin performanta sub control. - Rapoartele globale trebuie rulate pe un data mart/replica separata, nu pe baza de productie.
- Costurile reale: 25k-60k EUR pentru MVP solid; izolare mai stricta creste timeline-ul si OPEX.
Daca construiesti un SaaS multi-tenant si vrei o arhitectura care nu te incurca peste 12 luni, scrie-ne. In experienta noastra ca studio, livram rapid, dar cu protectii pentru productia reala. Contacteaza MTBYTE pe /contact.
URMATORUL PAS
Ti-a placut abordarea?
Aplicam aceleasi principii in proiectele clientilor: AI, automatizari, produse care nu se sting dupa lansare.