Multi-Tenant en PostgreSQL con NestJS y Prisma
11 min lectura

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

FactorShared TablesSeparate SchemasSeparate Databases
Tenant count1000+100–100010–100
Isolation needsBajoMedioAlto
CustomizationNingunaAlgunaTotal
Ops complexityBajaMediaAlta
Cross-tenant queriesFácilMedioDifícil
Backup granularityDatabaseSchema (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.