Cuando me tocó diseñar la base de datos para un sistema multi-tenant, la primera pregunta no fue técnica, fue conceptual: ¿cómo le doy a cada cliente su propio espacio sin multiplicar los costos de infraestructura y ajustándome a lo que la empresa tiene?
Este post documenta cómo lo resolví, por qué tomé las decisiones que tomé siendo completamente nuevo en esta arquitectura, y qué soluciones más sofisticadas existen para quien enfrenta este mismo problema hoy y también me gustaría que tú compartieras soluciones que has hecho y formas mejores de elaborar algo como esto.
¿Qué es multi-tenant?
Tal vez ni siquiera necesito explicarles esto, posiblemente sepas qué es si ya tienes tiempo en el área de la ingeniería de software y más si tu área es el backend.
Una arquitectura multi-tenant es aquella en la que una sola instancia de la aplicación sirve a múltiples clientes (tenants), donde cada cliente percibe que tiene su propio entorno aislado, pero en realidad comparten la misma infraestructura subyacente.
El reto principal no es hacerlo funcionar, es garantizar tres cosas:
- Los datos de un tenant nunca son visibles para otro.
- Todos los tenants operan sobre la misma versión del modelo de datos.
- La solución escala sin multiplicar exponencialmente los recursos del servidor.
El requerimiento que lo definió todo
Desde el inicio el requerimiento era claro y no negociable: cada cliente debía tener exactamente el mismo esquema de base de datos (que casi siempre es así), estructuralmente idéntico, con sus propios datos completamente aislados. No había margen para que un tenant divergiera del modelo.
Con eso sobre la mesa, la primera decisión real fue arquitectónica.
La primera decisión que tuve que tomar: ¿una base de datos por tenant o un schema por tenant?
La opción más intuitiva al principio es darle a cada cliente su propia base de datos. Parece la forma "más segura" y más aislada. Pero cuando lo analizas en un contexto real con recursos limitados y entorno dockerizado, esa opción se deshace rápido.
¿Por qué descarté base de datos por tenant?
Cada base de datos en PostgreSQL implica un proceso de servidor independiente. En Docker, eso significa un container separado por tenant con su propio consumo de RAM (~50–100 MB base), su propio connection pool, sus propios archivos en disco y su propio overhead operacional.
Con 10 tenants estaba corriendo 10 instancias de Postgres. Con 50, el servidor no aguantaría y la proyección de ventas del SaaS era alta. Además, cada operación de mantenimiento, backup, migración o monitoreo se multiplicaba por el número de tenants activos. El costo operacional crecía linealmente con el negocio, y eso no era sostenible (desde mi punto de vista) ya que estaba tratando de adaptarme a un problema que ya tenía encima y que ya causaba un dolor real en la empresa con otros proyectos.
¿Por qué elegí schema por tenant?
El aislamiento a nivel de schema de PostgreSQL cubría exactamente lo que necesitaba: separación lógica total de datos, con la misma estructura compartida, todo dentro de una sola instancia.
- Un solo container de Postgres.
- Un solo pool de conexiones gestionado con PgBouncer.
- Un solo proceso de backup.
- Todos los tenants se benefician automáticamente de cualquier mejora de recursos al servidor.
# Arquitectura elegida
[App] → [PgBouncer] → [PostgreSQL]
├── schema: tenant_empresa_a
├── schema: tenant_empresa_b
└── schema: tenant_empresa_c
# Alternativa descartada
[App] → [Postgres: empresa_a] ← 100MB RAM idle
→ [Postgres: empresa_b] ← 100MB RAM idle
→ [Postgres: empresa_c] ← 100MB RAM idle
La decisión fue clara: schema por tenant, en una sola instancia.
Resolviendo la conexión dinámica por tenant
La parte que más me costó entender al principio fue cómo conectar correctamente al schema correcto según la request que llegaba. La solución fue una PrismaConnectionFactory con scope de request en NestJS.
// prisma-connection.factory.ts
import { Inject, Injectable, Scope } from '@nestjs/common';
import { REQUEST } from '@nestjs/core';
import { PrismaPg } from '@prisma/adapter-pg';
import { PrismaClient } from '@prisma/client';
import { Request } from 'express';
import { Pool } from 'pg';
const clientMap = new Map<string, PrismaClient>();
@Injectable({ scope: Scope.REQUEST })
export class PrismaConnectionFactory {
constructor(@Inject(REQUEST) private readonly request: Request) {}
async getPrismaClient(): Promise<PrismaClient> {
const tenantId = this.resolveTenantFromRequest();
if (!clientMap.has(tenantId)) {
const client = await this.buildClientForTenant(tenantId);
clientMap.set(tenantId, client);
}
return clientMap.get(tenantId);
}
private resolveTenantFromRequest(): string {
return (this.request.headers['x-tenant-id'] as string) ?? 'public';
}
private async buildClientForTenant(tenantId: string): Promise<PrismaClient> {
const connectionString = this.buildConnectionString();
const pool = new Pool({ connectionString });
const adapter = new PrismaPg(pool, { schema: tenantId });
return new PrismaClient({ adapter });
}
private buildConnectionString(): string {
const { user, password, host, port, database } = this.getDbConfig();
return `postgres://${user}:${password}@${host}:${port}/${database}`;
}
private getDbConfig() {
return {
user: process.env.DB_USERNAME,
password: process.env.DB_PASSWORD,
host: process.env.DB_HOST,
port: process.env.DB_PORT,
database: process.env.DB_DATABASE,
};
}
}
clientMap fuera de la clase — vive a nivel de módulo para que persista entre instancias del factory. Como el scope es REQUEST, NestJS crea una instancia nueva por cada request, pero el mapa sobrevive a eso. Crear un PrismaClient tiene overhead real (pool de conexiones, handshake con PG), así que no queremos hacerlo en cada request si ya tenemos uno listo para ese tenant. Si tienes múltiples réplicas del servidor podrías mover esto a Redis, pero para un solo nodo es más simple y te ahorra dockerizar otro servicio.
Scope.REQUEST — hace que NestJS cree una instancia nueva de este factory por cada request HTTP, lo que nos permite leer los headers de esa request específica. REQUEST es un token especial de NestJS que inyecta el objeto de la request actual; solo funciona con Scope.REQUEST o Scope.TRANSIENT.
resolveTenantFromRequest — el tenant viene en el header x-tenant-id, que debe ser inyectado por el middleware de autenticación antes de llegar aquí. Si no viene (endpoints públicos, healthchecks), caemos al schema public. El factory no tiene lógica de negocio propia, solo confía en lo que viene en el request.
PrismaPg con { schema: tenantId } — esta es la decisión técnica central. En lugar de hacer SET search_path TO "tenantId" en cada query, que muta el estado de la sesión y puede filtrarse entre requests, el schema queda embebido como propiedad fija del adaptador. Prisma traduce todas las queries para usar ese schema sin que haya que modificar nada más en el resto de la aplicación.
buildConnectionString — todos los tenants se conectan a la misma base de datos física. El aislamiento lo da el schema, no credenciales distintas por tenant. Las credenciales viven en variables de entorno; en producción las provee el orquestador (Docker, K8s) o un secrets manager como AWS Secrets Manager o Vault.
El segundo reto: las migraciones
Una vez corriendo con schemas separados, el problema real llegó: ¿cómo sincronizo todos los schemas cuando el modelo de datos cambia?
Estaba usando Prisma como ORM, y aquí choqué con una limitación real. Prisma fue diseñado pensando en un modelo de datos único. No tiene soporte nativo para orquestar migraciones contra múltiples schemas de forma coordinada.
Las librerías de la comunidad existían, pero ninguna era oficial, ninguna tenía el soporte que yo necesitaba, y en ese momento no era el momento de apostar por una dependencia sin respaldo en un sistema productivo.
Así que diseñé mi propia solución (posiblemente aquí en su momento sobre-trabajé).
La solución: el schema public como fuente de verdad
La idea central fue tratar el schema public de PostgreSQL como el schema maestro o plantilla. La única fuente de verdad estructural del sistema.
Crear un nuevo tenant
La creación de un tenant pasaba por una capa de servicio que validaba el nombre del schema antes de tocar la base de datos, y un repositorio que ejecutaba todo dentro de una sola transacción.
// tenant-manager.service.ts
@Injectable()
export class TenantManagerService {
constructor(private readonly tenantRepo: TenantManagerRepository) {}
async createTenantSchema(dto: CreateTenantSchemaDto): Promise<TenantResult> {
this.validateSchemaName(dto.schema_name);
const result = await this.tenantRepo.setupTenant(dto);
if (!result.isNew) {
throw new HttpException('El nombre del tenant ya existe', 400);
}
return result;
}
private validateSchemaName(name: string): void {
if (!/^[a-z_][a-z0-9_]*$/.test(name)) {
throw new HttpException('Nombre de schema inválido', 400);
}
}
}
// tenant-manager.repository.ts
@Injectable()
export class TenantManagerRepository {
constructor(private readonly prisma: PrismaClient) {}
async setupTenant(dto: CreateTenantSchemaDto): Promise<TenantResult> {
const { schema_name } = dto;
const existing = await this.prisma.tenants.findUnique({ where: { schema_name } });
if (existing) {
return { success: true, schema: schema_name, isNew: false };
}
await this.prisma.$transaction(async (ptx) => {
await ptx.$executeRawUnsafe(`CREATE SCHEMA IF NOT EXISTS "${schema_name}"`);
await ptx.tenants.create({ data: { schema_name } });
// Replica toda la estructura del schema public al nuevo schema:
// enums, tablas, columnas, PKs, UKs, índices, secuencias y FKs
await this.clonePublicSchema(ptx, schema_name);
});
return { success: true, schema: schema_name, isNew: true };
}
}
El método clonePublicSchema era el corazón de la solución. Hacía la clonación completa del schema public al nuevo tenant en orden:
- Enums — se clonaban primero porque las tablas los referencian.
- Tablas y columnas — se creaban sin constraints para evitar problemas de orden.
- Primary keys y unique constraints — se añadían una vez creadas todas las tablas.
- Defaults y secuencias — incluyendo manejo especial para columnas
serialy columnas con default de tipo enum. - Índices — reescribiendo la definición para que apuntara al nuevo schema.
- Foreign keys — al final, cuando ya todas las referencias existían.
Todo dentro de la misma transacción, así que si cualquier paso fallaba, el schema quedaba sin crear y la tabla de tenants no se registraba.
El pipeline de CI/CD
Este script lo integré directamente en el pipeline de CI/CD, como un step previo al despliegue. Antes de que cualquier nueva versión de la app llegara a producción, todos los schemas ya estaban alineados y validados.
# .github/workflows/deploy.yml (fragmento)
jobs:
deploy:
steps:
- name: Apply migration to public schema
run: npx prisma migrate deploy
- name: Sync all tenant schemas
run: node scripts/sync-tenant-schemas.js
- name: Run integration tests
run: node scripts/test-tenant-schemas.js
- name: Deploy application
if: success()
run: ./scripts/deploy.sh
Las pruebas de integración verificaban que cada schema de tenant tuviera exactamente las mismas tablas, columnas, índices y constraints que el schema public. Si algún tenant fallaba la validación, el pipeline se detenía. Nada llegaba a producción con un schema inconsistente.
// scripts/test-tenant-schemas.ts
async function assertSchemaSync(tenantSlug: string): Promise<void> {
const client = await pool.connect();
try {
const { rows: publicColumns } = await client.query(`
SELECT table_name, column_name, data_type
FROM information_schema.columns
WHERE table_schema = 'public'
ORDER BY table_name, column_name
`);
const { rows: tenantColumns } = await client.query(`
SELECT table_name, column_name, data_type
FROM information_schema.columns
WHERE table_schema = $1
ORDER BY table_name, column_name
`, [tenantSlug]);
if (JSON.stringify(publicColumns) !== JSON.stringify(tenantColumns)) {
throw new Error(`Schema mismatch detected for tenant: ${tenantSlug}`);
}
console.log(`✓ [${tenantSlug}] schema in sync`);
} finally {
client.release();
}
}
Pasé cerca de una semana revisando documentación, evaluando opciones, buscando qué hacía la comunidad. Era la primera vez que enfrentaba esta arquitectura. La solución que construí fue a medida, manual y completamente bajo mi control. Con el tiempo el proyecto avanzó, se desplegó la versión oficial, y la solución se mantuvo funcional, rápida y estable.
Lo que aprendí después: soluciones más sofisticadas
Con el proyecto ya en producción y más experiencia acumulada, conocí enfoques más estandarizados que resuelven este mismo problema de forma más robusta. Los dejo aquí para quien esté evaluando su stack desde cero.
Flyway con schemas dinámicos
Flyway tiene soporte nativo para ejecutar el mismo conjunto de migraciones contra múltiples schemas en paralelo, usando placeholders configurables por schema. Permite orquestar el proceso completo desde una sola herramienta sin scripts custom.
flyway \
-schemas=tenant_a,tenant_b,tenant_c \
-placeholders.schemaName=tenant_a \
migrate
A diferencia de Prisma, Drizzle fue construido con un modelo más cercano a SQL puro. Tiene soporte explícito para schemas de PostgreSQL y permite manejar conexiones con schema dinámico de forma mucho más natural. Para un proyecto nuevo hoy, elegiría Drizzle sobre Prisma sin dudarlo en un contexto multi-tenant.
// Drizzle: schema dinámico por tenant
import { drizzle } from 'drizzle-orm/node-postgres';
import { pgSchema } from 'drizzle-orm/pg-core';
function getTenantSchema(slug: string) {
return pgSchema(slug);
}
const tenantSchema = getTenantSchema('empresa_a');
const users = tenantSchema.table('users', {
id: uuid('id').primaryKey().defaultRandom(),
email: varchar('email', { length: 255 }).notNull(),
});
MikroORM con SchemaGenerator
MikroORM expone un SchemaGenerator programático que puedes invocar por schema, lo que permite escribir un script limpio que aplica diferencias de schema (no SQL raw) contra cada tenant. Más declarativo y menos propenso a errores de orden de ejecución.
const generator = orm.getSchemaGenerator();
await generator.updateSchema({ schema: 'tenant_empresa_a' });
Una plataforma de gestión de cambios de base de datos diseñada para entornos enterprise. Soporta multi-tenant de forma nativa, con control de rollback, auditoría y flujos de aprobación. Demasiado para un proyecto pequeño, pero exactamente correcto para uno de escala media-grande.
Neon + branching por tenant
Para escenarios cloud-native, Neon ofrece branching de base de datos instantáneo. Cada tenant puede vivir en su propio branch lógico con aislamiento real, sin el costo de múltiples instancias. Un enfoque moderno que vale la pena evaluar si el stack vive completamente en la nube.
Conclusión
La arquitectura multi-tenant con schema por tenant en una sola instancia de PostgreSQL fue la decisión correcta para el contexto: recursos limitados, entorno dockerizado, y requerimiento de aislamiento por cliente sin overhead operacional.
El mayor desafío no fue la arquitectura en sí, sino el tooling alrededor de las migraciones. Prisma no estaba diseñado para este patrón, así que construí la solución a medida: el schema public como fuente de verdad, un script de sincronización, y validación integrada en el CI/CD antes de cada despliegue.
Funcionó. Y con el tiempo aprendí que la dirección era correcta, lo que hubiera cambiado es el tooling: Drizzle o MikroORM en lugar de Prisma, y posiblemente Flyway para orquestar las migraciones de forma más robusta.
Me gustaría saber y aprender de ustedes si tienen otro tipo de soluciones, retroalimentación y ejemplos que hayan hecho o documentación que les haya servido para abordar algo como esto.
