Cómo Estructurar un Backend SaaS Multi-Tenant
Una guía práctica de arquitectura SaaS multi-tenant — desde estrategias de aislamiento de base de datos hasta autenticación, facturación y feature flags.

En el momento en que decides que tu app servirá a más de una organización, cada decisión arquitectónica se bifurca. Diseño de base de datos, autenticación, autorización, facturación, incluso logging — todo ahora lleva la pregunta: ¿para cuál tenant?
He lanzado backends multi-tenant en dominios muy diferentes. Los patrones que sobreviven producción rara vez son los que se ven más limpios en una pizarra. Este artículo cubre las decisiones que realmente importan, las compensaciones de las que nadie te advierte y patrones de implementación concretos que puedes aprovechar.
Estrategias de Aislamiento de Base de Datos
Esta es la primera y más consecuente decisión. Si la tomas mal, te enfrentarás a una migración que toca cada consulta en tu código.
Hay tres enfoques principales:
| Estrategia | Aislamiento | Complejidad | Costo | Mejor Para |
|---|---|---|---|---|
| BD compartida, schema compartido | Bajo | Baja | Más bajo | SaaS en etapa temprana, < 100 tenants |
| BD compartida, schema por tenant | Medio | Media | Medio | Escala media, industrias reguladas |
| Base de datos por tenant | Alto | Alta | Más alto | Clientes enterprise, cumplimiento estricto |
Base de Datos Compartida, Schema Compartido
Los datos de cada tenant viven en las mismas tablas. Distingues las filas por una columna tenant_id. Aquí es donde la mayoría de los productos SaaS deberían comenzar.
La ventaja es la simplicidad. Una ruta de migración, un pool de conexiones, consultas directas. La desventaja es que una cláusula WHERE tenant_id = ? faltante significa una filtración de datos. No hay red de seguridad a nivel de infraestructura — el aislamiento está completamente en tu código de aplicación.
Este enfoque funciona hasta que las consultas de un tenant grande empiezan a degradar el rendimiento para todos los demás, o hasta que el equipo de cumplimiento de un cliente enterprise pregunta cómo sus datos están físicamente separados de los de la competencia. En ese punto, gradúas.
Schema por Tenant
Cada tenant obtiene su propio schema de PostgreSQL (o equivalente) dentro de una instancia de base de datos compartida. Las tablas son idénticas entre schemas, pero están físicamente separadas. Cambias el search path al momento de la conexión.
Esto te da aislamiento real sin administrar docenas de instancias de base de datos. Las migraciones son más complejas — las ejecutas N veces — pero herramientas como node-pg-migrate o el soporte multi-schema de Prisma lo manejan razonablemente bien.
El problema: el connection pooling se complica. Si estás usando PgBouncer, necesitas ser deliberado sobre cómo el cambio de schema interactúa con las conexiones del pool. He visto esto causar bugs sutiles donde el Tenant A momentáneamente lee el schema del Tenant B porque una conexión fue devuelta al pool a mitad de una transacción.
Base de Datos por Tenant
Aislamiento máximo. Cada tenant tiene una instancia de base de datos dedicada (o al menos una base de datos lógica dedicada). Enrutas conexiones dinámicamente basándote en el contexto del tenant.
Esto es costoso y operacionalmente pesado, pero algunos clientes pagarán por ello. Salud y servicios financieros frecuentemente lo requieren. El beneficio clave más allá del aislamiento es que puedes escalar, respaldar y restaurar tenants independientemente. Un vecino ruidoso no puede tumbar tu instancia compartida.
En la práctica, la mayoría de los equipos ejecutan un modelo híbrido: BD compartida para la mayoría, instancias dedicadas para cuentas enterprise que pagan por ello.
Autenticación y Resolución de Tenant
Cada solicitud entrante necesita responder dos preguntas: ¿quién es este usuario? y ¿a qué tenant pertenece?
Hay tres estrategias comunes para resolver el contexto del tenant:
Basado en subdominio: acme.tuapp.com se mapea al tenant Acme. Limpio, intuitivo, funciona genial para productos B2B donde cada cliente obtiene su propio espacio de trabajo. Extraes el slug del tenant del header Host.
Basado en claims JWT: El ID del tenant está embebido en el token de acceso al momento del login. No se necesita enrutamiento a nivel de infraestructura. Funciona bien para apps móviles y SPAs donde el enrutamiento por subdominio es impráctico.
Basado en headers: El cliente envía un header X-Tenant-ID. Simple pero peligroso si no se valida contra los permisos del usuario autenticado. Solo usa esto para llamadas internas servicio a servicio.
En la mayoría de los sistemas, combino la resolución por subdominio para el flujo de login inicial con claims JWT para las llamadas API subsecuentes. Así se ve el middleware:
import { Request, Response, NextFunction } from "express";
import { verifyToken } from "../lib/auth";
import { getTenantBySlug } from "../services/tenant";
export async function tenantMiddleware(
req: Request,
res: Response,
next: NextFunction
) {
// 1. Try JWT claim first (authenticated requests)
const token = req.headers.authorization?.replace("Bearer ", "");
if (token) {
const payload = verifyToken(token);
if (payload?.tenantId) {
req.tenantId = payload.tenantId;
return next();
}
}
// 2. Fall back to subdomain resolution
const host = req.hostname;
const slug = host.split(".")[0];
if (slug === "www" || slug === "app") {
return res.status(400).json({ error: "Tenant not identified" });
}
const tenant = await getTenantBySlug(slug);
if (!tenant || !tenant.isActive) {
return res.status(404).json({ error: "Tenant not found" });
}
req.tenantId = tenant.id;
next();
}
Monta esto antes de tus manejadores de rutas y cada función downstream tiene acceso a req.tenantId. El resto de tu aplicación nunca tiene que pensar en cómo funciona la resolución de tenant — simplemente lee el ID del contexto de la solicitud.
Alcance de Cada Consulta de Base de Datos
Una vez que tienes el contexto del tenant en cada solicitud, necesitas garantizar que se aplique a cada consulta. Faltar un alcance es una brecha de datos. Esto no es algo que dejes a la disciplina del desarrollador.
El mejor patrón que he encontrado es una capa de repositorio que aplica el alcance automáticamente:
import { db } from "../lib/database";
export function scopedQuery(tenantId: string) {
return {
async findMany<T>(table: string, where: Record<string, unknown> = {}): Promise<T[]> {
return db(table)
.where({ ...where, tenant_id: tenantId })
.select("*");
},
async findOne<T>(table: string, where: Record<string, unknown>): Promise<T | null> {
return db(table)
.where({ ...where, tenant_id: tenantId })
.first();
},
async insert<T>(table: string, data: Record<string, unknown>): Promise<T> {
return db(table)
.insert({ ...data, tenant_id: tenantId })
.returning("*")
.then((rows) => rows[0]);
},
async update(
table: string,
where: Record<string, unknown>,
data: Record<string, unknown>
): Promise<number> {
return db(table)
.where({ ...where, tenant_id: tenantId })
.update(data);
},
async remove(table: string, where: Record<string, unknown>): Promise<number> {
return db(table)
.where({ ...where, tenant_id: tenantId })
.del();
},
};
}
En tus manejadores de rutas, nunca construyes consultas crudas. Siempre pasas por la interfaz con alcance:
app.get("/api/projects", async (req, res) => {
const query = scopedQuery(req.tenantId);
const projects = await query.findMany("projects", { archived: false });
res.json(projects);
});
Si estás usando un ORM como Prisma, puedes lograr lo mismo con middleware que inyecta el filtro de tenant_id en cada llamada de findMany, findFirst, update y delete. El punto es hacer que el camino inseguro sea más difícil que el seguro.
Control de Acceso Basado en Roles Dentro de los Tenants
La multi-tenencia introduce un modelo de autorización de dos capas. Primero: ¿este usuario pertenece a este tenant? Segundo: ¿qué puede hacer dentro de él?
Mantengo esto simple con una tabla tenant_memberships:
CREATE TABLE tenant_memberships (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID NOT NULL REFERENCES users(id),
tenant_id UUID NOT NULL REFERENCES tenants(id),
role VARCHAR(50) NOT NULL DEFAULT 'member',
created_at TIMESTAMPTZ DEFAULT NOW(),
UNIQUE(user_id, tenant_id)
);
Los roles típicamente son owner, admin, member, y a veces viewer o un rol específico del dominio. Evita construir una matriz de permisos completa a menos que tu producto genuinamente lo necesite — la mayoría del SaaS B2B funciona bien con 3-4 roles.
La verificación de autorización ocurre después de la resolución del tenant:
export function requireRole(...roles: string[]) {
return async (req: Request, res: Response, next: NextFunction) => {
const membership = await db("tenant_memberships")
.where({ user_id: req.userId, tenant_id: req.tenantId })
.first();
if (!membership || !roles.includes(membership.role)) {
return res.status(403).json({ error: "Insufficient permissions" });
}
req.userRole = membership.role;
next();
};
}
// Usage
app.delete("/api/projects/:id", requireRole("owner", "admin"), async (req, res) => {
// only owners and admins reach this handler
});
Una decisión de diseño que ahorra dolores de cabeza después: siempre permite que un usuario pertenezca a múltiples tenants. Incluso si tu producto hoy es de un solo espacio de trabajo, en el momento en que agregues cuentas de agencia, revendedores de marca blanca o un modo de consultor, lo necesitarás.
Feature Flags por Tenant
No todos los tenants deberían ver todas las funcionalidades. A veces estás limitando por nivel de plan. A veces estás haciendo un despliegue gradual. A veces un cliente enterprise está pagando por una capacidad personalizada.
Uso una tabla simple tenant_features en lugar de incorporar un servicio de feature flags de terceros. Para la mayoría de los productos SaaS, esto es más que suficiente:
// Cache-backed feature flag check
const featureCache = new Map<string, Set<string>>();
export async function hasFeature(tenantId: string, feature: string): Promise<boolean> {
if (!featureCache.has(tenantId)) {
const rows = await db("tenant_features")
.where({ tenant_id: tenantId, enabled: true })
.select("feature_key");
featureCache.set(tenantId, new Set(rows.map((r) => r.feature_key)));
}
return featureCache.get(tenantId)!.has(feature);
}
// Invalidate on plan change or manual toggle
export function invalidateFeatureCache(tenantId: string) {
featureCache.delete(tenantId);
}
En los manejadores de rutas:
app.post("/api/analytics/export", async (req, res) => {
if (!(await hasFeature(req.tenantId, "analytics_export"))) {
return res.status(403).json({
error: "This feature is not available on your current plan",
upgrade: true,
});
}
// proceed with export
});
Cuando un tenant actualiza su plan, cambias las filas de funcionalidades relevantes e invalidas el caché. Directo, auditable y sin dependencias externas.
Integración de Facturación
Stripe es el estándar aquí por buena razón. El mapeo es natural: un Stripe Customer por tenant, una Subscription por tenant, los niveles de plan se mapean a Products y Prices.
La regla de diseño crítica: almacena el Stripe Customer ID en el registro del tenant, no en el registro del usuario. La facturación pertenece a la organización, no a la persona que ingresó la tarjeta de crédito.
El flujo de webhooks se ve así:
checkout.session.completed— adjunta el Stripe Customer ID al tenant, activa su suscripción.invoice.paid— extiende el período de facturación, actualizatenant.paidUntil.invoice.payment_failed— marca al tenant como moroso, envía correos de cobranza.customer.subscription.updated— sincroniza cambios de nivel de plan, actualiza feature flags correspondientes.customer.subscription.deleted— degrada al nivel gratuito o suspende.
La clave es la idempotencia. Stripe puede y enviará el mismo webhook múltiples veces. Cada handler debe ser seguro de ejecutar dos veces con el mismo payload del evento.
Aislamiento de Datos y Seguridad
Más allá del alcance de consultas, hay algunas cosas no obvias que deberías hacer bien desde el principio:
Row-Level Security (RLS): Si estás en PostgreSQL, habilita RLS como segunda línea de defensa. Incluso si tu código de aplicación tiene un bug, la base de datos misma rechazará devolver filas que no coincidan con el contexto del tenant actual. Establece app.current_tenant al momento de la conexión y escribe políticas contra ello.
Contexto de logging: Cada línea de log debe incluir tenantId. Cuando algo se rompe a las 3 AM, necesitas saber qué tenant está afectado sin escarbar en trazas de solicitudes. El logging estructurado con un campo de tenant integrado en el contexto del logger hace esto automático.
Respaldos y eliminación de datos: Los tenants abandonarán, y algunos invocarán el RGPD o derechos de eliminación de datos. Si todos tus datos están en tablas compartidas, purgar un solo tenant significa declaraciones DELETE cuidadosamente elaboradas a través de cada tabla. Con schema o base de datos por tenant, es un DROP SCHEMA o DROP DATABASE. Planifica esto desde el día uno.
Rate limiting por tenant: Un rate limiter global no es suficiente. La importación masiva de un tenant no debería consumir el presupuesto de rate de otro tenant. Aplica tu rate limiter por tenantId, no solo por IP o usuario.
Encriptación en reposo: Para industrias sensibles, encripta los datos del tenant en reposo con claves por tenant. Esto agrega complejidad a la gestión de claves, pero significa que revocar el acceso de un tenant es tan simple como destruir su clave. Ningún dato permanece legible en disco después del offboarding.
Reflexiones Finales
La multi-tenencia no es una librería que instalas. Es un conjunto de decisiones arquitectónicas que tocan cada capa de tu stack, desde la base de datos hasta el CDN. La buena noticia es que los patrones están bien entendidos. La parte difícil es aplicarlos con el nivel correcto de aislamiento para tu mercado y etapa específicos.
Comienza con todo compartido y una capa de alcance disciplinada. Agrega límites de aislamiento conforme tus clientes lo demanden. No sobre-ingenierices para requisitos de cumplimiento que aún no tienes, pero asegúrate de que tu arquitectura pueda evolucionar hacia un aislamiento más estricto sin una reescritura.
He construido sistemas multi-tenant desde plataformas de restaurantes hasta SaaS médico — cada uno con sus propios requisitos de aislamiento y escalamiento. Los patrones en este artículo son los que se mantuvieron en todos ellos.
Proyectos Relacionados
RestoHub
Los restaurantes dejan de perder el 30% con Uber Eats — obtienen su propio sistema de pedidos, menu, sitio web y programa de fidelizacion en una sola plataforma. Experiencia completa al estilo Uber Eats, pero el restaurante se queda con cada dolar.
TakeCare
Ahora un solo enfermero monitorea 250 pacientes a distancia — reemplazando llamadas telefonicas manuales y visitas domiciliarias en los hospitales mas grandes de Quebec. En funcionamiento en Jewish General, CHUM y Douglas Mental Health Institute.
Danil Ulmashev
Full Stack Developer
Interesado en trabajar juntos?