Multi-Tenant en PostgreSQL con NestJS y Prisma
Toda aplicación SaaS se enfrenta a la misma pregunta: ¿cómo almacenar datos para múltiples clientes en una sola base de datos? La respuesta afecta la seguridad, el rendimiento, la complejidad operativa y cómo escalas. Esta guía cubre los tres enfoques principales con implementaciones prácticas en PostgreSQL, NestJS y Prisma ORM.
Los Tres Enfoques
1. Shared Tables (Modelo Pool)
Todos los tenants comparten las mismas tablas con una columna tenant_id.
flowchart TD
subgraph Database ["Base de Datos Compartida (saas_db)"]
subgraph Tables ["Tablas Compartidas (Filtro por tenant_id)"]
Users["Tabla: users (id, tenant_id, name, email)"]
Orders["Tabla: orders (id, tenant_id, user_id, amount)"]
end
end
TenantAcme["Client / Tenant: acme"] -->|WHERE tenant_id = 'acme'| Users
TenantGlobex["Client / Tenant: globex"] -->|WHERE tenant_id = 'globex'| Users
2. Separate Schemas (Modelo Bridge)
Cada tenant obtiene su propio schema de PostgreSQL dentro de una database compartida.
flowchart TD
subgraph DB ["Database: saas_db"]
subgraph SchemaAcme ["Schema: acme"]
UsersA["users"]
OrdersA["orders"]
end
subgraph SchemaGlobex ["Schema: globex"]
UsersG["users"]
OrdersG["orders"]
end
end
TenantAcme["Tenant: acme"] -->|search_path = acme| SchemaAcme
TenantGlobex["Tenant: globex"] -->|search_path = globex| SchemaGlobex
3. Separate Databases (Modelo Silo)
Cada tenant obtiene su propia database de PostgreSQL.
flowchart TD
subgraph Server ["Instancia de PostgreSQL"]
subgraph DBAcme ["Database: tenant_acme"]
UsersA["users"]
OrdersA["orders"]
end
subgraph DBGlobex ["Database: tenant_globex"]
UsersG["users"]
OrdersG["orders"]
end
end
TenantAcme["Tenant: acme"] --> DBAcme
TenantGlobex["Tenant: globex"] --> DBGlobex
Enfoque 1: Shared Tables
Implementación DDL y RLS en PostgreSQL
-- Crear tablas con tenant_id
CREATE TABLE tenants (
id VARCHAR(50) PRIMARY KEY,
name VARCHAR(255) NOT NULL,
created_at TIMESTAMP DEFAULT now()
);
CREATE TABLE users (
id SERIAL PRIMARY KEY,
tenant_id VARCHAR(50) NOT NULL REFERENCES tenants(id),
name VARCHAR(255) NOT NULL,
email VARCHAR(255) NOT NULL,
created_at TIMESTAMP DEFAULT now(),
UNIQUE(tenant_id, id),
UNIQUE(tenant_id, email) -- Email único por tenant
);
CREATE TABLE orders (
id SERIAL PRIMARY KEY,
tenant_id VARCHAR(50) NOT NULL REFERENCES tenants(id),
user_id INTEGER NOT NULL,
amount NUMERIC(10,2) NOT NULL,
created_at TIMESTAMP DEFAULT now(),
FOREIGN KEY (tenant_id, user_id) REFERENCES users(tenant_id, id)
);
-- Crear índices incluyendo tenant_id
CREATE INDEX idx_users_tenant ON users(tenant_id);
CREATE INDEX idx_users_tenant_email ON users(tenant_id, email);
CREATE INDEX idx_orders_tenant ON orders(tenant_id);
CREATE INDEX idx_orders_tenant_user ON orders(tenant_id, user_id);
-- Habilitar Row Level Security (RLS)
ALTER TABLE users ENABLE ROW LEVEL SECURITY;
ALTER TABLE orders ENABLE ROW LEVEL SECURITY;
-- Crear rol de aplicación y políticas RLS
CREATE ROLE app_user;
GRANT SELECT, INSERT, UPDATE, DELETE ON ALL TABLES IN SCHEMA public TO app_user;
GRANT USAGE, SELECT ON ALL SEQUENCES IN SCHEMA public TO app_user;
CREATE POLICY tenant_isolation_users ON users
USING (tenant_id = current_setting('app.current_tenant'))
WITH CHECK (tenant_id = current_setting('app.current_tenant'));
CREATE POLICY tenant_isolation_orders ON orders
USING (tenant_id = current_setting('app.current_tenant'))
WITH CHECK (tenant_id = current_setting('app.current_tenant'));
Integración en NestJS con Prisma y Row Level Security (RLS)
Para aplicar RLS en Prisma, es necesario ejecutar SET LOCAL app.current_tenant antes de ejecutar las consultas dentro del contexto de la petición actual. En NestJS podemos lograr esto mediante AsyncLocalStorage e Interceptores/Middleware de NestJS, usando extensiones de Prisma ($extends).
1. Contexto de Tenant con AsyncLocalStorage (tenant.context.ts)
import { AsyncLocalStorage } from 'async_hooks';
export const tenantStorage = new AsyncLocalStorage<string>();
2. Middleware de NestJS (tenant.middleware.ts)
import { Injectable, NestMiddleware, BadRequestException } from '@nestjs/common';
import { Request, Response, NextFunction } from 'express';
import { tenantStorage } from './tenant.context';
@Injectable()
export class TenantMiddleware implements NestMiddleware {
use(req: Request, res: Response, next: NextFunction) {
const tenantId = req.headers['x-tenant-id'] as string;
if (!tenantId) {
throw new BadRequestException('El encabezado X-Tenant-ID es obligatorio.');
}
tenantStorage.run(tenantId, () => {
next();
});
}
}
3. Servicio de Prisma Extendido con RLS (prisma.service.ts)
import { Injectable, OnModuleInit } from '@nestjs/common';
import { PrismaClient } from '@prisma/client';
import { tenantStorage } from './tenant.context';
@Injectable()
export class PrismaService extends PrismaClient implements OnModuleInit {
async onModuleInit() {
await this.$connect();
}
// Cliente de Prisma configurado dinámicamente según el contexto del tenant
get tenantClient() {
const tenantId = tenantStorage.getStore();
if (!tenantId) {
throw new Error('No se ha definido un contexto de tenant para esta solicitud.');
}
return this.$extends({
query: {
$allModels: {
async $allOperations({ args, query }) {
// Ejecutamos en una transacción usando set_config
const [, result] = await PrismaService.prototype.$transaction.call(this, [
this.$executeRaw`SELECT set_config('app.current_tenant', ${tenantId}, TRUE)`,
query(args),
]);
return result;
},
},
},
});
}
}
4. Uso en un Servicio de NestJS (users.service.ts)
import { Injectable } from '@nestjs/common';
import { PrismaService } from './prisma.service';
@Injectable()
export class UsersService {
constructor(private readonly prisma: PrismaService) {}
async findAllUsers() {
// La consulta se filtra automáticamente gracias a RLS en PostgreSQL
return this.prisma.tenantClient.user.findMany();
}
async createUser(data: { name: string; email: string }) {
const tenantId = tenantStorage.getStore();
return this.prisma.tenantClient.user.create({
data: {
...data,
tenantId, // Debe coincidir con la variable establecida en RLS
},
});
}
}
Pros y Contras
Pros:
-
Despliegue (deployment) y mantenimiento simples.
-
Facilidad para realizar queries y analíticas cross-tenant.
-
Connection pooling eficiente.
-
Sin complejidad de schema migration por tenant.
Cons:
-
Riesgo de noisy neighbor (las queries de un tenant afectan a los demás).
-
Overhead de RLS en cada query.
-
Más difícil ofrecer personalizaciones específicas por tenant.
-
Single point of failure (punto único de fallo).
Enfoque 2: Separate Schemas
Implementación en PostgreSQL
-- Función para crear un nuevo schema por tenant
CREATE OR REPLACE FUNCTION create_tenant_schema(tenant_name TEXT)
RETURNS VOID AS $$
BEGIN
EXECUTE format('CREATE SCHEMA IF NOT EXISTS %I', tenant_name);
EXECUTE format('
CREATE TABLE %I.users (
id SERIAL PRIMARY KEY,
name VARCHAR(255) NOT NULL,
email VARCHAR(255) UNIQUE NOT NULL,
created_at TIMESTAMP DEFAULT now()
)', tenant_name);
EXECUTE format('
CREATE TABLE %I.orders (
id SERIAL PRIMARY KEY,
user_id INTEGER NOT NULL REFERENCES %I.users(id),
amount NUMERIC(10,2) NOT NULL,
created_at TIMESTAMP DEFAULT now()
)', tenant_name, tenant_name);
EXECUTE format('CREATE INDEX ON %I.users(email)', tenant_name);
EXECUTE format('CREATE INDEX ON %I.orders(user_id)', tenant_name);
END;
$$ LANGUAGE plpgsql;
SELECT create_tenant_schema('acme');
SELECT create_tenant_schema('globex');
Integración en NestJS con Prisma (Manejo Dinámico de Schemas)
Prisma permite configurar el parámetro ?schema= en la URL de conexión de PostgreSQL. Para manejar esquemas dinámicos, creamos un gestor de clientes de Prisma con almacenamiento en caché (connection pool por esquema).
1. Gestor de Clientes Prisma por Esquema (tenant-prisma-factory.service.ts)
import { Injectable, OnModuleDestroy } from '@nestjs/common';
import { PrismaClient } from '@prisma/client';
@Injectable()
export class TenantPrismaFactoryService implements OnModuleDestroy {
private clients: Map<string, PrismaClient> = new Map();
getPrismaClientForTenant(tenantSchema: string): PrismaClient {
if (!this.clients.has(tenantSchema)) {
const baseUrl = process.env.DATABASE_URL; // e.g., postgresql://user:pass@localhost:5432/saas_db
const tenantUrl = `${baseUrl}?schema=${tenantSchema}`;
const client = new PrismaClient({
datasources: {
db: {
url: tenantUrl,
},
},
});
this.clients.set(tenantSchema, client);
}
return this.clients.get(tenantSchema)!;
}
async onModuleDestroy() {
for (const client of this.clients.values()) {
await client.$disconnect();
}
}
}
2. Controlador y Servicio en NestJS (orders.service.ts)
import { Injectable, Inject, Scope } from '@nestjs/common';
import { REQUEST } from '@nestjs/core';
import { Request } from 'express';
import { TenantPrismaFactoryService } from './tenant-prisma-factory.service';
@Injectable({ scope: Scope.REQUEST })
export class OrdersService {
constructor(
@Inject(REQUEST) private readonly request: Request,
private readonly prismaFactory: TenantPrismaFactoryService,
) {}
private get prisma() {
const tenantSchema = this.request.headers['x-tenant-id'] as string;
return this.prismaFactory.getPrismaClientForTenant(tenantSchema);
}
async getOrders() {
return this.prisma.order.findMany();
}
}
Migraciones con Prisma en Múltiples Esquemas
Para aplicar migraciones de Prisma en cada esquema de tenant, podemos crear un script ejecutable dentro del proyecto NestJS:
import { PrismaClient } from '@prisma/client';
import { execSync } from 'child_process';
async function migrateAllSchemas() {
const mainPrisma = new PrismaClient();
// Obtener todos los esquemas de tenants creados
const schemas: { schema_name: string }[] = await mainPrisma.$queryRaw`
SELECT schema_name
FROM information_schema.schemata
WHERE schema_name NOT IN ('public', 'pg_catalog', 'information_schema')
`;
for (const { schema_name } of schemas) {
console.log(`Aplicando migraciones a schema: ${schema_name}`);
const tenantUrl = `${process.env.DATABASE_URL}?schema=${schema_name}`;
execSync(`npx prisma migrate deploy`, {
env: { ...process.env, DATABASE_URL: tenantUrl },
stdio: 'inherit',
});
}
await mainPrisma.$disconnect();
}
migrateAllSchemas().catch(console.error);
Pros y Contras
Pros:
-
Buen aislamiento de tenants dentro de una database compartida.
-
Personalizaciones específicas por tenant más fáciles.
-
Posibilidad de hacer dump y restore de schemas individuales mediante backups lógicos.
-
Mejor protección contra noisy neighbors que el enfoque de shared tables.
Cons:
-
Complejidad en las schema migrations (debes actualizar todos los schemas).
-
Connection pooling más desafiante.
-
Las queries cross-tenant requieren prefijos de schema.
-
Más difícil de monitorear y mantener a medida que crece el número de tenants.
Enfoque 3: Separate Databases
Implementación
# Crear base de datos para cada tenant
createdb -O app_user tenant_acme
createdb -O app_user tenant_globex
# Aplicar esquema mediante Prisma CLI
DATABASE_URL="postgresql://app_user:pass@localhost:5432/tenant_acme" npx prisma migrate deploy
DATABASE_URL="postgresql://app_user:pass@localhost:5432/tenant_globex" npx prisma migrate deploy
Ruteo de Conexiones en NestJS con Prisma
En NestJS creamos un servicio encargado de administrar los pools de conexión para cada base de datos independiente.
import { Injectable, OnModuleDestroy } from '@nestjs/common';
import { PrismaClient } from '@prisma/client';
@Injectable()
export class MultiDatabaseService implements OnModuleDestroy {
private tenantClients: Map<string, PrismaClient> = new Map();
getDatabaseClient(tenantId: string): PrismaClient {
if (!this.tenantClients.has(tenantId)) {
const dbHost = process.env.DB_HOST || 'localhost';
const dbPort = process.env.DB_PORT || '5432';
const dbUser = process.env.DB_USER || 'postgres';
const dbPass = process.env.DB_PASS || 'secret';
const dsn = `postgresql://${dbUser}:${dbPass}@${dbHost}:${dbPort}/tenant_${tenantId}?schema=public`;
const client = new PrismaClient({
datasources: {
db: { url: dsn },
},
});
this.tenantClients.set(tenantId, client);
}
return this.tenantClients.get(tenantId)!;
}
async onModuleDestroy() {
for (const client of this.tenantClients.values()) {
await client.$disconnect();
}
}
}
Pros y Contras
Pros:
-
El aislamiento más fuerte (seguridad y rendimiento).
-
Facilidad para hacer backup y restore por tenant.
-
Posibilidad de ubicar a los tenants de alto valor en hardware dedicado.
-
El modelo mental más simple.
Cons:
-
La mayor complejidad operativa.
-
Connection pooling por cada database.
-
Las analíticas cross-tenant requieren infraestructura adicional.
-
Mayor overhead de recursos.
Eligiendo el Enfoque Correcto
Matriz de Decisión
| Factor | Shared Tables | Separate Schemas | Separate Databases |
|---|---|---|---|
| Tenant count | 1000+ | 100–1000 | 10–100 |
| Isolation needs | Bajo | Medio | Alto |
| Customization | Ninguna | Alguna | Total |
| Ops complexity | Baja | Media | Alta |
| Cross-tenant queries | Fácil | Medio | Difícil |
| Backup granularity | Database | Schema (backups lógicos) | Por tenant |
Recomendaciones
Elige Shared Tables cuando:
-
Tienes muchos tenants pequeños (B2C SaaS).
-
Los tenants tienen schemas idénticos.
-
Necesitas analíticas cross-tenant fáciles.
-
La simplicidad operativa es tu prioridad.
Elige Separate Schemas cuando:
-
Tienes un conteo moderado de tenants.
-
Los tenants podrían necesitar ligeras personalizaciones.
-
Necesitas un mejor aislamiento que el de shared tables.
-
Se requieren restores individuales por tenant.
Elige Separate Databases cuando:
-
Los tenants tienen estrictos requisitos de compliance.
-
Tienes pocos clientes empresariales de alto valor.
-
Los tenants necesitan un aislamiento completo.
-
Se requieren garantías de rendimiento por cada tenant.
Enfoque Híbrido
Muchas aplicaciones SaaS utilizan un modelo híbrido:
flowchart TD
subgraph SharedDB ["Base de Datos Compartida (Shared Tables + RLS)"]
SmallCo1["tenant_id: small_co_1"]
SmallCo2["tenant_id: small_co_2"]
SmallCo3["tenant_id: small_co_3"]
end
subgraph DB_Enterprise1 ["Database: enterprise_acme (Dedicada)"]
AcmeData["Datos Acme"]
end
subgraph DB_Enterprise2 ["Database: enterprise_mega (Dedicada)"]
MegaData["Datos Mega"]
end
Rutea los tenants basándote en su plan usando NestJS y Prisma:
import { Injectable } from '@nestjs/common';
import { PrismaClient } from '@prisma/client';
export interface TenantInfo {
id: string;
plan: 'free' | 'pro' | 'enterprise';
customDbUrl?: string;
}
@Injectable()
export class HybridTenantService {
private dedicatedClients: Map<string, PrismaClient> = new Map();
private sharedClient: PrismaClient;
constructor() {
this.sharedClient = new PrismaClient({
datasources: { db: { url: process.env.SHARED_DATABASE_URL } },
});
}
getPrismaClient(tenant: TenantInfo): PrismaClient {
if (tenant.plan === 'enterprise' && tenant.customDbUrl) {
if (!this.dedicatedClients.has(tenant.id)) {
const client = new PrismaClient({
datasources: { db: { url: tenant.customDbUrl } },
});
this.dedicatedClients.set(tenant.id, client);
}
return this.dedicatedClients.get(tenant.id)!;
}
// Para planes free y pro, se utiliza la base de datos compartida
return this.sharedClient;
}
}
Esto te permite optimizar costos para los tenants pequeños mientras ofreces un aislamiento premium para los clientes enterprise que estén dispuestos a pagar por ello.
Resumen
La arquitectura multi-tenant correcta depende de tus necesidades específicas:
-
Comienza con shared tables para tener simplicidad y escala.
-
Añade Row Level Security para hacer cumplir el aislamiento de los tenants.
-
Considera separate schemas cuando necesites personalización por tenant.
-
Usa separate databases para requerimientos de alto aislamiento o para niveles enterprise.
Sin importar el enfoque que elijas, asegúrate de que el código de tu aplicación imponga de forma consistente los límites entre tenants. La simple ausencia de una cláusula WHERE o un mal manejo del contexto puede exponer los datos de un tenant a otro.