Franco Seiler — Software StudioFranco Seiler — Software StudioBootcampYouTube
← Volver al blog

Multi-tenancy: elegir entre una base, un esquema o una columna

Tres formas de aislar tenants en un SaaS, con los trade-offs reales. Por qué elegí columna discriminadora en una plataforma de consorcios.

Por Franco Seiler5 min de lecturaRead in English
Panel de administración de la plataforma Bellum

Cuando construís una plataforma que atiende a varias organizaciones, la primera decisión arquitectónica es cómo mantener sus datos separados. Si la errás, te espera una migración que toca cada consulta del código.

Pasé por esto construyendo Bellum, una plataforma para administradores de consorcios donde cada uno gestiona sus propios edificios, propietarios y expensas. Estas son las tres opciones, lo que cuesta cada una y por qué elegí la que elegí.

Opción 1: una base de datos por tenant

Cada organización tiene su propia base. Aislamiento total.

A favor: un bug en tu capa de consultas no puede filtrar datos entre tenants, porque no hay camino físico entre ellos. Restaurar el backup de un cliente no toca a nadie más. Si una regulación exige que los datos de un cliente vivan en un país determinado, apuntás esa base ahí y listo.

En contra: las migraciones se vuelven una operación. Un cambio de esquema tiene que correr N veces, y si falla en el tenant 40 de 100 te queda la flota partida al medio. Los pools de conexiones se multiplican. Y dar de alta un cliente deja de ser un INSERT para pasar a ser aprovisionar infraestructura.

Usala cuando: tenés pocos tenants, grandes y que pagan bien, o un requisito regulatorio que te obliga.

Opción 2: un esquema por tenant

Una sola base, pero cada tenant con su esquema: tenant_42.propietarios, tenant_43.propietarios. Postgres lo maneja de forma nativa.

A favor: aislamiento decente con una sola conexión. Respaldar un tenant es un dump a nivel esquema.

En contra: las migraciones siguen corriendo N veces, con el mismo riesgo de falla parcial. Y Postgres se pone lento con miles de esquemas: el catálogo del sistema crece y las herramientas que lo inspeccionan empiezan a arrastrarse.

Usala cuando: querés aislamiento y sabés que vas a quedarte en decenas o pocos cientos de tenants.

Opción 3: una columna discriminadora

Todo comparte tablas y cada fila lleva un tenant_id.

A favor: una migración, un pool de conexiones, un plan de consulta. Dar de alta un tenant es insertar una fila. Las métricas entre tenants —"¿cuál es el promedio de todos nuestros clientes?"— son una consulta normal en vez de un fan-out entre bases.

En contra: el aislamiento depende enteramente de tu código. Una consulta a la que le falte el WHERE tenant_id = ? devuelve datos de otra organización, y ese es el peor bug que un SaaS puede publicar.

Qué elegí y cómo lo protegí

Fui con la columna discriminadora. Bellum tiene muchos tenants chicos, cada uno un administrador con un puñado de edificios, y el alta tenía que ser inmediata.

Pero "el aislamiento depende de tu código" no es algo que se deje librado a la disciplina. Dos resguardos:

Row-level security en la base. La regla deja de ser una convención y pasa a ser algo que el motor hace cumplir:

ALTER TABLE propietarios ENABLE ROW LEVEL SECURITY;

CREATE POLICY tenant_isolation ON propietarios
  USING (tenant_id = current_setting('app.tenant_id')::uuid);

Con esto activo, una consulta que se olvide del filtro devuelve cero filas en vez de los datos de otro. El modo de falla pasa de fuga silenciosa a bug visible.

Una capa de repositorio que no se puede olvidar. Ningún código de funcionalidad escribe consultas crudas:

// Cada método recibe el tenant explícitamente. No hay valor por defecto
// ni forma de obtener un cliente sin alcance.
export function forTenant(tenantId: string) {
  return {
    propietarios: {
      findMany: (where = {}) =>
        db.propietario.findMany({ where: { ...where, tenantId } }),
      create: (data) =>
        db.propietario.create({ data: { ...data, tenantId } }),
    },
  };
}

Cinturón y tiradores a propósito: el repositorio hace que lo correcto sea fácil, y RLS hace que lo incorrecto falle.

La comparación honesta

Base por tenant Esquema por tenant Columna discriminadora
Aislamiento Físico Fuerte Depende del código + RLS
Costo de migración N corridas N corridas 1 corrida
Alta de cliente Aprovisionar infra Crear esquema INSERT
Escala a miles Difícil No Sí
Consultas entre tenants Muy difícil Difícil Triviales
Vecino ruidoso Aislado Parcial Compartido

Qué pasa cuando un tenant crece más que el resto

Esta es la pregunta que lo define a largo plazo, y la columna discriminadora tiene una respuesta real: es la única de la que se puede salir de a poco. Cuando un tenant se vuelve lo bastante grande como para molestar a los demás, movés ese a su propia base y dejás a todo el mundo donde está. Tu código ya recibe tenantId en todos lados; lo que cambia es a qué conexión resuelve.

Ir en la dirección contraria —arrancar con una base por tenant y consolidar— significa fusionar N esquemas con claves primarias que chocan. Mucho peor.

Cierre

No hay una respuesta correcta universal, pero sí un valor por defecto razonable: empezá con la columna discriminadora, protegela con row-level security y separá tenants individuales cuando alguno se lo gane. El aislamiento físico desde el día uno es un costo real que se paga en dolor de migraciones, y solo vale la pena cuando la regulación o el tamaño del cliente lo exigen.

El error no es elegir una u otra. Es elegir sin saber qué resignaste.

Recibí los artículos por mail

Un mail cuando publicamos algo nuevo. Sin spam, sin promociones, y te das de baja en un clic.

Multi-tenancy: elegir entre una base, un esquema o una columna | Franco Seiler