SaaS multi-tenant: aislamiento, escalado y datos
Análisis a fondo de la arquitectura multi-tenant: aislamiento, partición de bases de datos, enrutamiento, cuotas y compartido frente a base por tenant.

El espectro de multi-tenencia
La multi-tenencia no es una elección binaria entre «todo compartido» y «todo separado». Es un espectro, y la posición correcta en él depende de tus requisitos de cumplimiento, expectativas de los clientes, estructura de costos y capacidad del equipo.
En un extremo, todos los tenants comparten una base de datos, una instancia de aplicación y una capa de caché. En el otro, cada tenant obtiene una base de datos dedicada, computación dedicada y red dedicada. La mayoría de los sistemas en producción se sitúan en algún punto intermedio.
Modelos de aislamiento de tenant
type IsolationModel = "shared" | "schema-per-tenant" | "database-per-tenant";
interface TenantConfig {
id: string;
name: string;
isolationModel: IsolationModel;
tier: "free" | "pro" | "enterprise";
region: string;
resourceQuota: ResourceQuota;
}
interface ResourceQuota {
maxStorageGB: number;
maxRequestsPerMinute: number;
maxConcurrentConnections: number;
maxComputeUnits: number;
}
// ❌ One-size-fits-all isolation — forces enterprise customers
// into shared infrastructure they don't want
function getConnection(): DatabaseConnection {
return sharedPool.getConnection();
}
// ✅ Tier-based isolation — matching isolation to customer needs
function getTenantConnection(tenant: TenantConfig): DatabaseConnection {
switch (tenant.isolationModel) {
case "database-per-tenant":
return getDedicatedConnection(tenant.id);
case "schema-per-tenant":
return getSchemaConnection(tenant.id);
case "shared":
return getSharedConnection(tenant.id);
}
}Las tablas compartidas con una columna tenant_id son las más baratas de operar pero las más difíciles de asegurar. Una cláusula WHERE ausente en una sola consulta filtra datos entre tenants. El esquema por tenant proporciona un aislamiento más fuerte con una sobrecarga moderada. La base de datos por tenant ofrece el aislamiento más fuerte y el cumplimiento más sencillo, pero el costo operativo más alto.
Enrutamiento de peticiones y resolución de tenant
Cada petición debe resolverse a un tenant antes de que se ejecute cualquier lógica de negocio. La resolución debe ser rápida, cacheada e imposible de evitar.
interface TenantResolver {
resolve(req: IncomingRequest): Promise<TenantConfig | null>;
}
class SubdomainTenantResolver implements TenantResolver {
constructor(
private readonly cache: Cache,
private readonly tenantRepo: TenantRepository
) {}
async resolve(req: IncomingRequest): Promise<TenantConfig | null> {
const host = req.headers.host || "";
const subdomain = host.split(".")[0];
if (!subdomain || subdomain === "www" || subdomain === "app") {
return null;
}
// Check cache first
const cached = await this.cache.get<TenantConfig>(
`tenant:${subdomain}`
);
if (cached) return cached;
const tenant = await this.tenantRepo.findBySubdomain(subdomain);
if (!tenant) return null;
await this.cache.set(`tenant:${subdomain}`, tenant, { ttl: 300 });
return tenant;
}
}
// Middleware that enforces tenant resolution
function tenantMiddleware(
resolver: TenantResolver
): RequestMiddleware {
return async (req, res, next) => {
const tenant = await resolver.resolve(req);
if (!tenant) {
res.status(404).json({ error: "Tenant not found" });
return;
}
if (tenant.tier === "free" && tenant.resourceQuota) {
const allowed = await checkRateLimit(
tenant.id,
tenant.resourceQuota.maxRequestsPerMinute
);
if (!allowed) {
res.status(429).json({ error: "Rate limit exceeded" });
return;
}
}
// Attach tenant to request context
req.tenant = tenant;
next();
};
}Estrategias de base de datos para multi-tenencia
La capa de base de datos es donde la multi-tenencia se complica. La seguridad a nivel de fila, el pool de conexiones, las migraciones y las copias de seguridad se comportan de manera diferente según el modelo de aislamiento.
// Shared database with row-level security
class SharedTenantRepository<T extends { tenantId: string }> {
constructor(
private readonly db: Database,
private readonly tableName: string
) {}
async findAll(tenantId: string): Promise<T[]> {
// Always filter by tenant — never trust application code alone
return this.db.query<T>(
`SELECT * FROM ${this.tableName} WHERE tenant_id = $1`,
[tenantId]
);
}
async create(tenantId: string, data: Omit<T, "tenantId">): Promise<T> {
const columns = Object.keys(data);
const values = Object.values(data);
return this.db.queryOne<T>(
`INSERT INTO ${this.tableName}
(tenant_id, ${columns.join(", ")})
VALUES ($1, ${columns.map((_, i) => `$${i + 2}`).join(", ")})
RETURNING *`,
[tenantId, ...values]
);
}
async delete(tenantId: string, id: string): Promise<boolean> {
const result = await this.db.execute(
`DELETE FROM ${this.tableName} WHERE id = $1 AND tenant_id = $2`,
[id, tenantId]
);
return result.rowCount > 0;
}
}
// Schema-per-tenant strategy
class SchemaTenantManager {
constructor(private readonly db: Database) {}
async createTenantSchema(tenantId: string): Promise<void> {
const schemaName = this.sanitizeSchemaName(tenantId);
await this.db.execute(`CREATE SCHEMA IF NOT EXISTS "${schemaName}"`);
// Run migrations within the tenant schema
await this.db.execute(`SET search_path TO "${schemaName}"`);
await this.runMigrations();
await this.db.execute(`SET search_path TO public`);
}
async getTenantConnection(tenantId: string): Promise<Database> {
const schemaName = this.sanitizeSchemaName(tenantId);
const conn = await this.db.getConnection();
await conn.execute(`SET search_path TO "${schemaName}"`);
return conn;
}
private sanitizeSchemaName(tenantId: string): string {
return `tenant_${tenantId.replace(/[^a-zA-Z0-9_]/g, "")}`;
}
private async runMigrations(): Promise<void> {
// Apply schema migrations
}
}Caché y trabajos en segundo plano conscientes del tenant
Las claves de caché deben incluir el ID del tenant. Los trabajos en segundo plano deben transportar el contexto del tenant. Sin estas salvaguardas, las colisiones de caché filtran datos y los trabajos se ejecutan en el contexto equivocado.
class TenantCache {
constructor(private readonly cache: Cache) {}
async get<T>(tenantId: string, key: string): Promise<T | null> {
return this.cache.get<T>(this.tenantKey(tenantId, key));
}
async set<T>(
tenantId: string,
key: string,
value: T,
ttl?: number
): Promise<void> {
await this.cache.set(this.tenantKey(tenantId, key), value, { ttl });
}
async invalidateAll(tenantId: string): Promise<void> {
const pattern = `tenant:${tenantId}:*`;
await this.cache.deletePattern(pattern);
}
private tenantKey(tenantId: string, key: string): string {
return `tenant:${tenantId}:${key}`;
}
}
// Background job with tenant context
interface TenantJob<T = unknown> {
tenantId: string;
jobType: string;
payload: T;
priority: number;
scheduledAt: Date;
}
class TenantJobProcessor {
constructor(
private readonly queue: JobQueue,
private readonly tenantManager: SchemaTenantManager
) {}
async enqueue<T>(
tenantId: string,
jobType: string,
payload: T
): Promise<string> {
const job: TenantJob<T> = {
tenantId,
jobType,
payload,
priority: this.getTenantPriority(tenantId),
scheduledAt: new Date(),
};
return this.queue.add(job);
}
async process(job: TenantJob): Promise<void> {
// Establish tenant context before processing
const db = await this.tenantManager.getTenantConnection(
job.tenantId
);
try {
const handler = this.getHandler(job.jobType);
await handler.execute(job.payload, db);
} finally {
await db.release();
}
}
private getTenantPriority(tenantId: string): number {
// Enterprise tenants get higher job priority
return 1;
}
private getHandler(jobType: string): JobHandler {
// Resolve handler by type
return {} as JobHandler;
}
}Conclusiones clave
La arquitectura multi-tenant es un espectro de compensaciones entre aislamiento, costo y complejidad operativa. Comienza con infraestructura compartida y filtrado de tenant a nivel de fila para productos en etapa inicial. Pasa a esquema por tenant cuando el cumplimiento requiera un aislamiento más fuerte. Reserva la base de datos por tenant para clientes enterprise que la necesiten explícitamente y estén dispuestos a pagar por ella.
Cada capa debe ser consciente del tenant: enrutamiento de peticiones, consultas de base de datos, claves de caché, trabajos en segundo plano y registros. Una sola capa que olvide el contexto del tenant crea una fuga de datos. Construye la resolución de tenant como middleware que se ejecute antes de cualquier lógica de negocio, y haz imposible ejecutar consultas sin un contexto de tenant.
Diseña el camino de migración entre modelos de aislamiento desde el principio. El peor resultado es quedar atascado en tablas compartidas cuando tu cliente más grande exige infraestructura dedicada. Abstrae la capa de base de datos detrás de un repositorio consciente del tenant para que el modelo de aislamiento pueda cambiar sin reescribir el código de la aplicación.


