Prisma + multi-tenancy: una base, muchos tenants, sin caos.
El reflejo cuando tu primer cliente grande pide una base dedicada es decir que sí. Ese reflejo escala hasta que te mata.
Cerrás tu primer cliente enterprise. El equipo de seguridad pregunta "¿dónde va a vivir nuestra data?". La respuesta honesta es "en la misma base que el resto, separada por una foreign key". El instinto es suavizarla: "podemos darles una base dedicada". Suena más enterprise. El cliente dice que sí. Levantás una base para ellos.
Un año después tenés cuarenta clientes, quince en bases dedicadas. Cada migración de schema son cuarenta deploys. Cada backup son cuarenta backups. Cada alerta se dispara cuarenta veces. La decisión de "darle una base dedicada" se volvió la realidad operativa del equipo.
La respuesta limpia es la primera. Multi-tenancy en una base compartida, con tenant_id en cada fila, escala más lejos de lo que la mayoría cree.
La forma
Cada tabla tiene un tenant_id. Cada query filtra por él. Los índices empiezan con él. Las constraints de unicidad están scopeadas por él.
model User {
id String @id @default(cuid())
email String
tenantId String
tenant Tenant @relation(fields: [tenantId], references: [id])
@@unique([tenantId, email])
@@index([tenantId])
}
email es único dentro de un tenant, no global. tenantId lidera el índice así cada query scopeada lo usa. Una base de Postgres, un schema, una historia de migraciones.
El middleware
Una columna tenant_id no enforcea nada por sí sola. El enforcement es un middleware de Prisma que inyecta el filtro automáticamente.
prisma.$use(async (params, next) => {
const tenantId = currentTenantId(); // del contexto del request
if (!tenantId) throw new Error("Falta el contexto de tenant");
if (TENANT_SCOPED_MODELS.has(params.model ?? "")) {
if (params.action === "findMany" || params.action === "findFirst") {
params.args.where = { ...params.args.where, tenantId };
}
if (params.action === "create") {
params.args.data = { ...params.args.data, tenantId };
}
// lo mismo para update, delete, count, etc.
}
return next(params);
});
El código de aplicación escribe prisma.user.findMany({ where: { email } }) y el middleware inyecta el filtro de tenant. Un desarrollador que se olvida de scopear por tenant no filtra accidentalmente entre tenants, la query nunca llega a la base sin él.
Lo que conseguís
- Una migración aplica para todos. El schema es el contrato; todos lo consumen.
- Una base para backupear, monitorear, escalar. Escalado vertical o read replicas son una decisión, no cuarenta.
- Queries cross-tenant cuando las necesitás. Dashboards operativos y reconciliación de billing son un
WHERE tenant_id IN (...)de distancia, no un problema de federación. - Onboarding rápido. Un tenant nuevo es una fila en
Tenant. Sin infra que provisionar, sin schema que replicar.
La trampa contra la que tenés que defender
La query cross-tenant accidental. Un desarrollador escribe prisma.project.findMany({ where: { name: "..." } }) sin filtro de tenant, y un tenant ve la data de otro.
Defensa en dos capas:
- El middleware: deny por default. Cualquier query contra un modelo tenant-scoped sin filtro de tenant tira excepción.
- Tests: por cada query tenant-scoped, verificá que el tenant A devuelve cero filas pertenecientes al tenant B.
Para productos con compliance estricto, sumá una tercera: políticas de Row-Level Security de Postgres. Enforcean a nivel de base, inmune a bugs del middleware. Más trabajo de setup, segunda línea de defensa.
Cuándo las bases dedicadas sí ganan
Tres casos donde el modelo compartido se rompe:
- Residencia estricta a nivel de base. Algunos reguladores exigen no solo "la data se queda en la región" sino "la data está en una base que ningún otro cliente toca".
- Un tenant cuya carga eclipsa al resto. Cuando un solo cliente justifica su propia infraestructura, dale la suya. El costo compartido deja de ser compartido.
- Variantes de schema customer-specific. Campos custom están bien con JSONB. Cuando el schema diverge demasiado, una base separada es más limpia.
La señal en los tres: el costo de cargar a ese cliente en la base compartida es más que el costo de operar una separada.
El camino
Empezá compartido. El schema tiene tenantId en todos lados, el middleware lo enforcea, los tests verifican el aislamiento. Podés servir cientos de tenants en una sola base antes de que el escalado sea una pregunta.
Cuando el primer cliente pida una base dedicada, preguntá por qué. Si la respuesta es "lo pidió nuestro equipo de seguridad", mostrales tu modelo de aislamiento. La mayoría lo acepta. Los pocos que no, están señalando algo del deal.
Cuando sí splitees a un tenant, hacelo porque sus números te obligan, no como concesión de venta.
Lo que hay que evitar es empezar single-tenant y tratar de pasarse a multi-tenant después. Esa migración son meses de trabajo. Empezar multi-tenant y splitear selectivamente unos tenants grandes son días por split. La dirección por default importa más que la excepción eventual.
Elegí compartido. Construí el aislamiento bien. Splitea cuando los números lo digan.