Saltar al contenido

Diseño de arquitecturas SaaS multicliente que escalan

Diseña SaaS multicliente con aislamiento de base de datos, middleware por tenant, cuotas, partición y límites de seguridad que escalan a diez mil.

4 min de lectura
Diagrama de arquitectura multicliente que muestra una capa de aplicación compartida con particiones de datos aisladas por tenant

El espectro de aislamiento

El multitenencia va desde completamente compartido (una base de datos, un esquema) hasta completamente aislado (bases de datos separadas por tenant). La elección correcta depende de tus requisitos de cumplimiento, la cantidad esperada de tenants y el presupuesto operativo. La mayoría de equipos empiezan compartiendo y se arrepienten; entender los compromisos desde el principio evita migraciones dolorosas.

Middleware de contexto del tenant

Cada solicitud debe transportar la identidad del tenant. Extráela una vez en el middleware y propágala durante todo el ciclo de vida de la solicitud.

tstypescript
// ❌ Passing tenantId manually through every function
// async function getOrders(tenantId: string, userId: string) { ... }
// async function getProducts(tenantId: string, category: string) { ... }
 
// ✅ Tenant context propagated via AsyncLocalStorage
import { AsyncLocalStorage } from "node:async_hooks";
 
interface TenantContext {
  tenantId: string;
  plan: "free" | "pro" | "enterprise";
  databaseSchema: string;
}
 
const tenantStorage = new AsyncLocalStorage<TenantContext>();
 
function getTenantContext(): TenantContext {
  const ctx = tenantStorage.getStore();
  if (!ctx) throw new Error("No tenant context — middleware not applied");
  return ctx;
}
 
// Express middleware
function tenantMiddleware(
  req: Request,
  res: Response,
  next: NextFunction
): void {
  const tenantId = req.headers["x-tenant-id"] as string;
 
  if (!tenantId) {
    res.status(400).json({ error: "Missing tenant identifier" });
    return;
  }
 
  const tenant = tenantRegistry.get(tenantId);
  if (!tenant) {
    res.status(404).json({ error: "Tenant not found" });
    return;
  }
 
  tenantStorage.run(
    {
      tenantId: tenant.id,
      plan: tenant.plan,
      databaseSchema: `tenant_${tenant.id}`,
    },
    () => next()
  );
}

Estrategias de aislamiento de base de datos

Existen tres patrones comunes: esquema compartido con columna de tenant, un esquema por tenant y una base de datos por tenant.

tstypescript
// Strategy 1: Shared schema — tenant_id column on every table
// Pros: Simple, low overhead
// Cons: One bad query leaks data across tenants
 
class SharedSchemaRepository {
  async getOrders(userId: string): Promise<Order[]> {
    const { tenantId } = getTenantContext();
 
    // Every query MUST include tenant_id — missing it leaks data
    return db.query(
      `SELECT * FROM orders WHERE tenant_id = $1 AND user_id = $2`,
      [tenantId, userId]
    );
  }
}
 
// Strategy 2: Schema-per-tenant — PostgreSQL schemas
// Pros: Strong isolation, easy per-tenant backup/restore
// Cons: Schema migrations must run N times
 
class SchemaPerTenantRepository {
  private getSchema(): string {
    return getTenantContext().databaseSchema;
  }
 
  async getOrders(userId: string): Promise<Order[]> {
    const schema = this.getSchema();
 
    // search_path isolates queries to tenant schema
    await db.query(`SET search_path TO ${schema}`);
    return db.query(
      `SELECT * FROM orders WHERE user_id = $1`,
      [userId]
    );
  }
}
 
// Strategy 3: Database-per-tenant — separate connections
// Pros: Complete isolation, per-tenant scaling
// Cons: Connection pool overhead, operational complexity
 
class DatabasePerTenantRepository {
  constructor(private pools: Map<string, DatabasePool>) {}
 
  private getPool(): DatabasePool {
    const { tenantId } = getTenantContext();
    const pool = this.pools.get(tenantId);
    if (!pool) throw new Error(`No database pool for tenant ${tenantId}`);
    return pool;
  }
 
  async getOrders(userId: string): Promise<Order[]> {
    const pool = this.getPool();
    return pool.query(
      `SELECT * FROM orders WHERE user_id = $1`,
      [userId]
    );
  }
}

Seguridad a nivel de fila para esquemas compartidos

Al usar esquemas compartidos, la seguridad a nivel de fila de PostgreSQL previene fugas de datos a nivel de base de datos, incluso si el código de la aplicación olvida el filtro de tenant.

sqlsql
-- Enable RLS on the orders table
ALTER TABLE orders ENABLE ROW LEVEL SECURITY;
 
-- Policy: users can only see rows matching their tenant
CREATE POLICY tenant_isolation ON orders
  USING (tenant_id = current_setting('app.current_tenant')::uuid);
 
-- Force RLS even for table owners
ALTER TABLE orders FORCE ROW LEVEL SECURITY;
tstypescript
// Set tenant context at the database session level
async function withTenantSession<T>(
  pool: Pool,
  tenantId: string,
  operation: (client: PoolClient) => Promise<T>
): Promise<T> {
  const client = await pool.connect();
 
  try {
    // Set tenant for RLS policies
    await client.query(
      `SET app.current_tenant = $1`,
      [tenantId]
    );
 
    return await operation(client);
  } finally {
    // Reset to prevent tenant leakage on connection reuse
    await client.query(`RESET app.current_tenant`);
    client.release();
  }
}
 
// Usage — no tenant_id in WHERE clause needed
const orders = await withTenantSession(pool, tenantId, async (client) => {
  // RLS automatically filters to current tenant
  const result = await client.query(`SELECT * FROM orders WHERE user_id = $1`, [
    userId,
  ]);
  return result.rows;
});

Cuotas de recursos y limitación de tasa

Un tenant que consume todos los recursos degrada el servicio para todos. Implementa cuotas por tenant a nivel de aplicación.

tstypescript
interface TenantQuota {
  maxRequestsPerMinute: number;
  maxStorageBytes: number;
  maxUsersPerTenant: number;
}
 
const PLAN_QUOTAS: Record<string, TenantQuota> = {
  free: {
    maxRequestsPerMinute: 60,
    maxStorageBytes: 100 * 1024 * 1024,
    maxUsersPerTenant: 5,
  },
  pro: {
    maxRequestsPerMinute: 600,
    maxStorageBytes: 10 * 1024 * 1024 * 1024,
    maxUsersPerTenant: 50,
  },
  enterprise: {
    maxRequestsPerMinute: 6000,
    maxStorageBytes: 100 * 1024 * 1024 * 1024,
    maxUsersPerTenant: 500,
  },
};
 
class TenantRateLimiter {
  private counters = new Map<string, { count: number; resetAt: number }>();
 
  check(tenantId: string, plan: string): { allowed: boolean; retryAfter?: number } {
    const quota = PLAN_QUOTAS[plan];
    if (!quota) return { allowed: false };
 
    const now = Date.now();
    const counter = this.counters.get(tenantId);
 
    if (!counter || counter.resetAt < now) {
      this.counters.set(tenantId, {
        count: 1,
        resetAt: now + 60_000,
      });
      return { allowed: true };
    }
 
    if (counter.count >= quota.maxRequestsPerMinute) {
      return {
        allowed: false,
        retryAfter: Math.ceil((counter.resetAt - now) / 1000),
      };
    }
 
    counter.count++;
    return { allowed: true };
  }
}

Migraciones conscientes del tenant

Un esquema por tenant requiere ejecutar migraciones en todos los esquemas de tenant. Automatízalo con un ejecutor de migraciones que rastree el estado de migración por tenant.

tstypescript
async function migrateAllTenants(
  migration: Migration,
  tenants: Tenant[]
): Promise<MigrationReport> {
  const results: MigrationReport = { succeeded: [], failed: [] };
 
  // Run migrations sequentially to avoid overloading the database
  for (const tenant of tenants) {
    try {
      await db.query(`SET search_path TO ${tenant.schema}`);
      await db.query(migration.sql);
 
      await db.query(
        `INSERT INTO migration_history (version, applied_at)
         VALUES ($1, NOW())`,
        [migration.version]
      );
 
      results.succeeded.push(tenant.id);
    } catch (error) {
      results.failed.push({
        tenantId: tenant.id,
        error: error instanceof Error ? error.message : "Unknown error",
      });
      // Continue with other tenants — don't let one failure block all
    }
  }
 
  return results;
}

Conclusiones clave

Las decisiones de arquitectura multicliente se acumulan con el tiempo. La estrategia de aislamiento que elijas —esquema compartido, esquema por tenant o base de datos por tenant— afecta la seguridad, las operaciones y el escalamiento durante toda la vida del producto. Empieza con un esquema por tenant cuando necesites aislamiento fuerte sin la sobrecarga operativa de bases de datos separadas.

Usa AsyncLocalStorage para propagar el contexto del tenant de forma implícita en lugar de pasar IDs de tenant por cada firma de función. Habilita la seguridad a nivel de fila como red de seguridad para esquemas compartidos: atrapa las consultas donde el código de la aplicación olvida el filtro de tenant. Implementa limitación de tasa y cuotas de recursos por tenant desde el principio, porque eventualmente un tenant ruidoso consumirá todos los recursos compartidos. Cada tenant debería experimentar la aplicación como si fuera el único cliente, sin importar cuántos tenants compartan la infraestructura.

Wilfredo Rujel

Wilfredo Rujel

Ingeniero de Software Full Stack

Compartir esta publicaciónX