Multi-Tenant no PostgreSQL com NestJS e Prisma

Toda aplicação SaaS enfrenta a mesma pergunta: como armazenar dados para múltiplos clientes em um único banco de dados? A resposta afeta a segurança, o desempenho, a complexidade operacional e a escalabilidade. Este guia cobre as três abordagens principais com implementações práticas no PostgreSQL, NestJS e Prisma ORM.

Multi-Tenant no PostgreSQL com NestJS e Prisma
11 min de leitura
Nesta página

Toda aplicação SaaS enfrenta a mesma pergunta: como armazenar dados para múltiplos clientes em um único banco de dados? A resposta afeta a segurança, o desempenho, a complexidade operacional e a escalabilidade. Este guia cobre as três abordagens principais com implementações práticas no PostgreSQL, NestJS e Prisma ORM.

As Três Abordagens

1. Shared Tables (Modelo Pool)

Todos os tenants compartilham as mesmas tabelas com uma coluna tenant_id.

flowchart TD
    subgraph Database ["Banco de Dados Compartilhado (saas_db)"]
        subgraph Tables ["Tabelas Compartilhadas (Filtro por tenant_id)"]
            Users["Tabela: users (id, tenant_id, name, email)"]
            Orders["Tabela: orders (id, tenant_id, user_id, amount)"]
        end
    end
    TenantAcme["Cliente / Tenant: acme"] -->|WHERE tenant_id = 'acme'| Users
    TenantGlobex["Cliente / Tenant: globex"] -->|WHERE tenant_id = 'globex'| Users

2. Separate Schemas (Modelo Bridge)

Cada tenant obtém seu próprio schema do PostgreSQL dentro de um database compartilhado.

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 obtém seu próprio database do PostgreSQL.

flowchart TD
    subgraph Server ["Instância do 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

Abordagem 1: Shared Tables

Implementação DDL e RLS no PostgreSQL

-- Criar tabelas com 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) -- E-mail ú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)
);

-- Criar índices incluindo 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;

-- Criar papel de aplicação e 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'));

Integração no NestJS com Prisma e Row Level Security (RLS)

Para aplicar o RLS no Prisma, é necessário executar SET LOCAL app.current_tenant antes de executar as consultas dentro do contexto da requisição atual. No NestJS podemos alcançar isso por meio do AsyncLocalStorage e Interceptores/Middleware do NestJS, usando extensões do Prisma ($extends).

1. Contexto de Tenant com AsyncLocalStorage (tenant.context.ts)

import { AsyncLocalStorage } from 'async_hooks';

export const tenantStorage = new AsyncLocalStorage<string>();

2. Middleware do 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('O cabeçalho X-Tenant-ID é obrigatório.');
        }

        tenantStorage.run(tenantId, () => {
            next();
        });
    }
}

3. Serviço do Prisma Estendido com 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 do Prisma configurado dinamicamente segundo o contexto do tenant
    get tenantClient() {
        const tenantId = tenantStorage.getStore();

        if (!tenantId) {
            throw new Error('Nenhum contexto de tenant foi definido para esta requisição.');
        }

        return this.$extends({
            query: {
            $allModels: {
                async $allOperations({ args, query }) {
                // Executamos em uma transação 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 em um Serviço do 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() {
        // A consulta é filtrada automaticamente graças ao RLS no 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, // Deve coincidir com a variável estabelecida no RLS
            },
        });
    }
}

Prós e Contras

Prós:

  • Implantação (deployment) e manutenção simples.

  • Facilidade para realizar queries e analíticas cross-tenant.

  • Connection pooling eficiente.

  • Sem complexidade de schema migration por tenant.

Contras:

  • Risco de noisy neighbor (as queries de um tenant afetam os demais).

  • Overhead de RLS em cada query.

  • Mais difícil oferecer personalizações específicas por tenant.

  • Single point of failure (ponto único de falha).

Abordagem 2: Separate Schemas

Implementação no PostgreSQL

-- Função para criar um novo 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');

Integração no NestJS com Prisma (Gestão Dinâmica de Schemas)

O Prisma permite configurar o parâmetro ?schema= na URL de conexão do PostgreSQL. Para gerenciar esquemas dinâmicos, criamos um gerenciador de clientes do Prisma com armazenamento em cache (connection pool por esquema).

1. Gerenciador 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 e Serviço no 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();
    }
}

Migrações com Prisma em Múltiplos Esquemas

Para aplicar migrações do Prisma em cada esquema de tenant, podemos criar um script executável dentro do projeto NestJS:

import { PrismaClient } from '@prisma/client';
import { execSync } from 'child_process';

async function migrateAllSchemas() {
    const mainPrisma = new PrismaClient();

    // Obter todos os esquemas de tenants criados
    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 migrações ao 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);

Prós e Contras

Prós:

  • Bom isolamento de tenants dentro de um database compartilhado.

  • Personalizações específicas por tenant mais fáceis.

  • Possibilidade de fazer dump e restore de schemas individuais por meio de backups lógicos.

  • Melhor proteção contra noisy neighbors que a abordagem de shared tables.

Contras:

  • Complexidade nas schema migrations (você deve atualizar todos os schemas).

  • Connection pooling mais desafiador.

  • As queries cross-tenant exigem prefixos de schema.

  • Mais difícil de monitorar e manter à medida que o número de tenants cresce.

Abordagem 3: Separate Databases

Implementação

# Criar banco de dados para cada tenant
createdb -O app_user tenant_acme
createdb -O app_user tenant_globex

# Aplicar esquema por meio do 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

Roteamento de Conexões no NestJS com Prisma

No NestJS, criamos um serviço encarregado de administrar os pools de conexão para cada banco de dados independente.

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();
        }
    }
}

Prós e Contras

Prós:

  • O isolamento mais forte (segurança e desempenho).

  • Facilidade para fazer backup e restore por tenant.

  • Possibilidade de alocar os tenants de alto valor em hardware dedicado.

  • O modelo mental mais simples.

Contras:

  • Maior complexidade operacional.

  • Connection pooling por cada database.

  • As analíticas cross-tenant exigem infraestrutura adicional.

  • Maior overhead de recursos.

Escolhendo a Abordagem Correta

Matriz de Decisão

FatorShared TablesSeparate SchemasSeparate Databases
Tenant count1000+100–100010–100
Isolation needsBaixoMédioAlto
CustomizationNenhumaAlgumaTotal
Ops complexityBaixaMédiaAlta
Cross-tenant queriesFácilMédioDifícil
Backup granularityDatabaseSchema (backups lógicos)Por tenant

Recomendações

Escolha Shared Tables quando:

  • Você tem muitos tenants pequenos (B2C SaaS).

  • Os tenants possuem schemas idênticos.

  • Você precisa de analíticas cross-tenant fáceis.

  • A simplicidade operacional é sua prioridade.

Escolha Separate Schemas quando:

  • Você tem uma contagem moderada de tenants.

  • Os tenants podem precisar de leves personalizações.

  • Você precisa de um isolamento melhor que o de shared tables.

  • Requerem-se restores individuais por tenant.

Escolha Separate Databases quando:

  • Os tenants possuem estritos requisitos de compliance.

  • Você tem poucos clientes corporativos de alto valor.

  • Os tenants precisam de um isolamento completo.

  • Requerem-se garantias de desempenho para cada tenant.

Abordagem Híbrida

Muitas aplicações SaaS utilizam um modelo híbrido:

flowchart TD
    subgraph SharedDB ["Banco de Dados Compartilhado (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["Dados Acme"]
    end

    subgraph DB_Enterprise2 ["Database: enterprise_mega (Dedicada)"]
        MegaData["Dados Mega"]
    end

Roteie os tenants com base no plano deles usando NestJS e 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 planos free e pro, utiliza-se o banco de dados compartilhado
        return this.sharedClient;
    }
}

Isso permite otimizar custos para os tenants pequenos enquanto oferece um isolamento premium para os clientes enterprise dispostos a pagar por isso.

Resumo

A arquitetura multi-tenant correta depende das suas necessidades específicas:

  • Comece com shared tables para ter simplicidade e escala.

  • Adicione Row Level Security para impor o isolamento dos tenants.

  • Considere separate schemas quando precisar de personalização por tenant.

  • Use separate databases para requisitos de alto isolamento ou para níveis enterprise.

Independentemente da abordagem escolhida, certifique-se de que o código da sua aplicação imponha de forma consistente os limites entre tenants. A simples ausência de uma cláusula WHERE ou um mau gerenciamento do contexto pode expor os dados de um tenant para outro.

Arraste para mover o diagrama. Use + e − para dar zoom, as setas para movê-lo e 0 para redefinir a visualização.

Vamos trabalhar juntos

Engenheiro de Software e desenvolvedor Senior Full-Stack, trabalho remotamente a partir de Santa Cruz, Bolívia. Aberto a novas oportunidades.