Skip to main content
backend15 de marzo de 202617 min de lectura

Patrones de Arquitectura Multi-Tenant: Más Allá de lo Básico

Patrones avanzados de arquitectura multi-tenant para plataformas SaaS — desde estrategias de aislamiento de datos hasta personalización por tenant y escalamiento.

saasarchitecturemulti-tenancy
Patrones de Arquitectura Multi-Tenant: Más Allá de lo Básico

La mayoría de las guías de multi-tenencia se detienen en "agrega una columna tenant_id y filtra tus consultas." Eso te lleva a través de los primeros meses. Luego un tenant con 10x los datos empieza a degradar el rendimiento para todos los demás, un prospecto enterprise pregunta sobre marca blanca, y un socio quiere integrar su propio procesador de pagos. De repente lo básico no es suficiente.

Este artículo cubre los patrones que he implementado en sistemas multi-tenant en producción que van más allá del alcance de consultas. Estas son las decisiones arquitectónicas que separan un prototipo de una plataforma.

Patrones Avanzados de Aislamiento

El modelo de tres niveles de base de datos compartida, schema por tenant y base de datos por tenant está bien entendido. Lo que se discute menos es el enfoque híbrido que la mayoría de los sistemas en producción terminan usando.

Aislamiento por Niveles Según Segmento de Cliente

En la práctica, rara vez eliges un nivel de aislamiento y lo aplicas uniformemente. En cambio, escalas tu aislamiento basándote en lo que cada segmento de clientes necesita y está dispuesto a pagar.

interface TenantConfig {
  id: string;
  slug: string;
  isolationLevel: "shared" | "schema" | "dedicated";
  databaseUrl?: string; // only for dedicated tenants
  schemaName?: string;  // only for schema-isolated tenants
}

function getConnectionForTenant(tenant: TenantConfig) {
  switch (tenant.isolationLevel) {
    case "shared":
      return getSharedPool();
    case "schema":
      return getSchemaConnection(tenant.schemaName!);
    case "dedicated":
      return getDedicatedConnection(tenant.databaseUrl!);
  }
}

El nivel compartido sirve a tus clientes de autoservicio. El aislamiento por schema sirve a clientes de mercado medio que necesitan documentación de cumplimiento. Las bases de datos dedicadas sirven a cuentas enterprise que requieren separación física de datos.

La restricción clave de diseño: tu código de aplicación no debería saber ni preocuparse en qué nivel está un tenant. La resolución de conexión ocurre en el middleware, y todo lo que está downstream usa la misma interfaz de consulta. Si un manejador de ruta tiene que verificar el nivel de aislamiento para decidir cómo consultar datos, tu abstracción está filtrándose.

Row-Level Security como Red de Seguridad

El Row-Level Security de PostgreSQL es la funcionalidad más subutilizada en arquitecturas multi-tenant. Incluso con un alcance de consultas disciplinado en tu capa de aplicación, RLS proporciona una garantía a nivel de base de datos de que un tenant no puede acceder a los datos de otro tenant.

ALTER TABLE orders ENABLE ROW LEVEL SECURITY;

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

ALTER TABLE orders FORCE ROW LEVEL SECURITY;

A nivel de conexión, estableces el contexto del tenant antes de ejecutar cualquier consulta:

async function withTenantContext<T>(
  tenantId: string,
  callback: (client: PoolClient) => Promise<T>
): Promise<T> {
  const client = await pool.connect();
  try {
    await client.query("SET app.current_tenant = $1", [tenantId]);
    return await callback(client);
  } finally {
    await client.query("RESET app.current_tenant");
    client.release();
  }
}

Con esto implementado, incluso si tu código de aplicación tiene un bug que omite la cláusula WHERE tenant_id = ?, la base de datos misma filtrará los resultados. Esto ha detectado bugs reales en producción para mí. Un desarrollador escribió una consulta de reportes que hacía join entre tablas y olvidó el filtro de tenant en una de ellas — RLS silenciosamente devolvió solo las filas correctas en lugar de filtrar datos.

El FORCE ROW LEVEL SECURITY es importante. Sin él, los propietarios de la tabla (típicamente el rol que usa tu aplicación) omiten las políticas RLS. Con él, las políticas se aplican a todos.

Pipeline de Middleware con Contexto de Tenant

El middleware de resolución de tenant que cubrí en mi artículo anterior sobre backends multi-tenant maneja lo básico: extraer el tenant del subdominio o JWT, adjuntarlo a la solicitud. Pero un sistema en producción necesita un pipeline de middleware más rico que construya un contexto completo de tenant.

interface TenantContext {
  id: string;
  slug: string;
  config: TenantConfig;
  plan: PlanTier;
  features: Set<string>;
  limits: TenantLimits;
  branding?: TenantBranding;
}

async function buildTenantContext(tenantId: string): Promise<TenantContext> {
  // Cache this aggressively — it's read on every request
  const cacheKey = `tenant:${tenantId}:context`;
  const cached = await redis.get(cacheKey);
  if (cached) return JSON.parse(cached);

  const [tenant, plan, features, limits, branding] = await Promise.all([
    db("tenants").where({ id: tenantId }).first(),
    db("tenant_plans").where({ tenant_id: tenantId, active: true }).first(),
    db("tenant_features").where({ tenant_id: tenantId, enabled: true }).select("feature_key"),
    db("tenant_limits").where({ tenant_id: tenantId }).first(),
    db("tenant_branding").where({ tenant_id: tenantId }).first(),
  ]);

  const context: TenantContext = {
    id: tenant.id,
    slug: tenant.slug,
    config: tenant.config,
    plan: plan.tier,
    features: new Set(features.map((f) => f.feature_key)),
    limits: limits ?? DEFAULT_LIMITS,
    branding: branding ?? undefined,
  };

  await redis.set(cacheKey, JSON.stringify(context), "EX", 300);
  return context;
}

El middleware luego adjunta este contexto completo a la solicitud:

export async function tenantContextMiddleware(
  req: Request,
  res: Response,
  next: NextFunction
) {
  if (!req.tenantId) return next();

  try {
    req.tenantContext = await buildTenantContext(req.tenantId);

    // Check if tenant is active
    if (req.tenantContext.config.status === "suspended") {
      return res.status(403).json({
        error: "Account suspended",
        reason: req.tenantContext.config.suspensionReason,
      });
    }

    next();
  } catch (error) {
    next(error);
  }
}

Cada manejador downstream ahora tiene acceso al contexto completo del tenant sin hacer llamadas adicionales a la base de datos. El caché de cinco minutos significa que un cambio de plan se propaga rápidamente, pero no estás consultando la base de datos en cada solicitud para verificar si una funcionalidad está habilitada.

Configuración Dinámica por Tenant

Más allá de los feature flags, los tenants frecuentemente necesitan comportamiento configurable. Una plataforma de restaurantes podría permitir que cada restaurante establezca sus propios horarios de corte de pedidos, tasas de impuestos, zonas de entrega y preferencias de notificación. Una herramienta de gestión de proyectos podría permitir que cada espacio de trabajo configure campos personalizados, etapas de flujo de trabajo y reglas de notificación.

El patrón que uso separa la configuración en una columna JSON validada por schema:

import { z } from "zod";

const TenantSettingsSchema = z.object({
  timezone: z.string().default("UTC"),
  locale: z.string().default("en"),
  currency: z.string().default("USD"),
  notifications: z.object({
    emailDigest: z.enum(["daily", "weekly", "never"]).default("daily"),
    slackWebhook: z.string().url().optional(),
    webhookUrl: z.string().url().optional(),
  }).default({}),
  limits: z.object({
    maxUsersOverride: z.number().optional(),
    maxStorageMbOverride: z.number().optional(),
    apiRateLimitOverride: z.number().optional(),
  }).default({}),
  customFields: z.array(z.object({
    key: z.string(),
    label: z.string(),
    type: z.enum(["text", "number", "date", "select"]),
    options: z.array(z.string()).optional(),
    required: z.boolean().default(false),
  })).default([]),
});

type TenantSettings = z.infer<typeof TenantSettingsSchema>;

Los ajustes se almacenan como JSONB en PostgreSQL, lo que te da capacidades de indexación y consulta mientras mantienes el schema flexible:

async function getTenantSettings(tenantId: string): Promise<TenantSettings> {
  const row = await db("tenant_settings")
    .where({ tenant_id: tenantId })
    .first();

  return TenantSettingsSchema.parse(row?.settings ?? {});
}

async function updateTenantSettings(
  tenantId: string,
  updates: Partial<TenantSettings>
): Promise<TenantSettings> {
  const current = await getTenantSettings(tenantId);
  const merged = { ...current, ...updates };
  const validated = TenantSettingsSchema.parse(merged);

  await db("tenant_settings")
    .insert({
      tenant_id: tenantId,
      settings: validated,
      updated_at: new Date(),
    })
    .onConflict("tenant_id")
    .merge();

  // Invalidate cache
  await redis.del(`tenant:${tenantId}:context`);

  return validated;
}

El schema de Zod sirve doble propósito: valida los ajustes al escribir y proporciona valores predeterminados al leer. Si agregas un nuevo ajuste, los tenants existentes automáticamente obtienen el valor predeterminado sin una migración de datos.

Feature Flags por Tenant

Los flags booleanos simples no son suficientes para una plataforma madura. Necesitas soporte para despliegues por porcentaje, control basado en plan y overrides por tenant.

interface FeatureFlag {
  key: string;
  defaultEnabled: boolean;
  rolloutPercentage: number; // 0-100
  planMinimum?: PlanTier;
  tenantOverrides: Map<string, boolean>; // explicit per-tenant overrides
}

class FeatureFlagService {
  private flags: Map<string, FeatureFlag>;

  constructor(private cache: CacheStore) {
    this.flags = new Map();
  }

  async isEnabled(tenantId: string, featureKey: string): Promise<boolean> {
    const flag = await this.getFlag(featureKey);
    if (!flag) return false;

    // Explicit per-tenant override takes precedence
    if (flag.tenantOverrides.has(tenantId)) {
      return flag.tenantOverrides.get(tenantId)!;
    }

    // Plan-based gating
    if (flag.planMinimum) {
      const tenantPlan = await this.getTenantPlan(tenantId);
      if (planRank(tenantPlan) < planRank(flag.planMinimum)) {
        return false;
      }
    }

    // Percentage rollout — deterministic based on tenant ID
    if (flag.rolloutPercentage < 100) {
      const hash = this.hashTenantFeature(tenantId, featureKey);
      return hash % 100 < flag.rolloutPercentage;
    }

    return flag.defaultEnabled;
  }

  private hashTenantFeature(tenantId: string, featureKey: string): number {
    const str = `${tenantId}:${featureKey}`;
    let hash = 0;
    for (let i = 0; i < str.length; i++) {
      hash = ((hash << 5) - hash + str.charCodeAt(i)) | 0;
    }
    return Math.abs(hash);
  }
}

El hash determinístico es importante para los despliegues. Un tenant consistentemente ve la funcionalidad o no la ve — no quieres que la vea en una solicitud y no en la siguiente. El hash se computa desde el ID del tenant y la clave de funcionalidad, así que diferentes funcionalidades pueden desplegarse a diferentes subconjuntos de tenants.

En los manejadores de rutas, la verificación es limpia:

app.get("/api/v2/analytics", async (req, res) => {
  const useNewAnalytics = await featureFlags.isEnabled(
    req.tenantId,
    "analytics_v2"
  );

  if (useNewAnalytics) {
    return res.json(await analyticsV2.getDashboard(req.tenantId));
  }

  return res.json(await analyticsV1.getDashboard(req.tenantId));
});

Integraciones Específicas por Tenant

Los tenants enterprise querrán conectar sus propias herramientas: su propia cuenta de Stripe para procesamiento de pagos, su propia cuenta de SendGrid para emails, su propio bucket S3 para almacenamiento de archivos. Tu plataforma necesita soportar esto sin convertirse en una pesadilla de configuración.

El patrón es un registro de integraciones que resuelve las credenciales correctas por tenant:

interface IntegrationConfig {
  provider: string;
  credentials: Record<string, string>; // encrypted at rest
  settings: Record<string, unknown>;
  isCustom: boolean; // true = tenant's own account, false = platform default
}

class IntegrationRegistry {
  async getIntegration(
    tenantId: string,
    integrationType: "email" | "payment" | "storage" | "sms"
  ): Promise<IntegrationConfig> {
    // Check for tenant-specific integration first
    const custom = await db("tenant_integrations")
      .where({ tenant_id: tenantId, type: integrationType, active: true })
      .first();

    if (custom) {
      return {
        provider: custom.provider,
        credentials: await decrypt(custom.encrypted_credentials),
        settings: custom.settings,
        isCustom: true,
      };
    }

    // Fall back to platform defaults
    return this.getPlatformDefault(integrationType);
  }
}

La capa de servicio luego usa el registro para obtener el cliente correcto:

class EmailService {
  constructor(private integrations: IntegrationRegistry) {}

  async sendEmail(tenantId: string, email: EmailPayload): Promise<void> {
    const config = await this.integrations.getIntegration(tenantId, "email");

    const client = this.createClient(config);
    await client.send({
      from: config.settings.fromAddress as string,
      ...email,
    });
  }

  private createClient(config: IntegrationConfig): EmailClient {
    switch (config.provider) {
      case "sendgrid":
        return new SendGridClient(config.credentials.apiKey);
      case "ses":
        return new SESClient(config.credentials);
      case "resend":
        return new ResendClient(config.credentials.apiKey);
      default:
        throw new Error(`Unknown email provider: ${config.provider}`);
    }
  }
}

Esto significa que un tenant puede usar la cuenta compartida de SendGrid de la plataforma mientras otro usa su propia instancia de Amazon SES con su propio dominio y reputación. El resto del código de tu aplicación no sabe ni le importa — llama a emailService.sendEmail() y la capa de integración maneja el enrutamiento.

La seguridad de credenciales no es negociable aquí. Las claves API proporcionadas por el tenant deben estar encriptadas en reposo, preferiblemente con claves de encriptación por tenant. Usa algo como AWS KMS o HashiCorp Vault para manejar esto — no construyas tu propia gestión de claves de encriptación.

Migración de Datos Entre Niveles de Aislamiento

Uno de los desafíos operacionales más complicados en sistemas multi-tenant es migrar un tenant de un nivel de aislamiento a otro. Un tenant en crecimiento podría moverse de tablas compartidas a su propio schema. Un acuerdo enterprise podría requerir migrar un tenant a una base de datos dedicada.

La migración necesita ser de cero tiempo de inactividad, lo que significa que no puedes simplemente hacer dump y restaurar. Aquí está el patrón que uso:

interface TenantMigration {
  tenantId: string;
  fromLevel: IsolationLevel;
  toLevel: IsolationLevel;
  status: "pending" | "syncing" | "verifying" | "cutover" | "complete" | "failed";
  startedAt: Date;
  completedAt?: Date;
}

class TenantMigrator {
  async migrateToSchema(tenantId: string): Promise<void> {
    const migration = await this.createMigration(tenantId, "shared", "schema");

    try {
      // 1. Create the target schema with all tables
      await this.createSchema(tenantId);
      await this.updateStatus(migration, "syncing");

      // 2. Copy existing data to the new schema
      await this.copyData(tenantId, "public", `tenant_${tenantId}`);

      // 3. Set up Change Data Capture to sync ongoing writes
      const cdcStream = await this.startCDC(tenantId, "public", `tenant_${tenantId}`);

      // 4. Verify data consistency
      await this.updateStatus(migration, "verifying");
      const isConsistent = await this.verifyConsistency(tenantId);
      if (!isConsistent) throw new Error("Data consistency check failed");

      // 5. Cutover: update tenant config to point to new schema
      await this.updateStatus(migration, "cutover");
      await db("tenants").where({ id: tenantId }).update({
        isolation_level: "schema",
        schema_name: `tenant_${tenantId}`,
      });

      // 6. Invalidate caches
      await redis.del(`tenant:${tenantId}:context`);

      // 7. Stop CDC and clean up source data
      await cdcStream.stop();
      await this.cleanupSourceData(tenantId, "public");

      await this.updateStatus(migration, "complete");
    } catch (error) {
      await this.updateStatus(migration, "failed");
      await this.rollback(migration);
      throw error;
    }
  }
}

El paso de Change Data Capture es crítico. Entre copiar los datos iniciales y realizar el cutover, se están produciendo nuevas escrituras contra las tablas compartidas. CDC captura esas escrituras y las reproduce en el nuevo schema para que no se pierda nada.

En la práctica, uso la replicación lógica de PostgreSQL para esto. Creas una publicación en las tablas origen filtrada por tenant_id, y una suscripción en el schema destino. Una vez que el lag de replicación está cerca de cero, realizas el cutover.

La ruta de rollback es igualmente importante. Si algo falla durante la migración, necesitas poder deshacer todo de forma limpia. Esto significa mantener los datos origen intactos hasta que la migración esté verificada y el tenant haya estado operando en el nuevo schema durante un período de confianza (típicamente espero 48 horas antes de limpiar los datos origen).

Monitoreo y Observabilidad por Tenant

En un sistema multi-tenant, "la API está lenta" no es accionable. Necesitas saber qué tenant está afectado, qué consultas están lentas y si el problema es específico del tenant (vecino ruidoso, dataset grande) o de toda la plataforma.

import { metrics } from "./lib/metrics"; // Prometheus, Datadog, etc.

function tenantMetricsMiddleware(req: Request, res: Response, next: NextFunction) {
  const start = Date.now();

  res.on("finish", () => {
    const duration = Date.now() - start;
    const labels = {
      tenant_id: req.tenantId,
      method: req.method,
      route: req.route?.path ?? "unknown",
      status: String(res.statusCode),
    };

    metrics.histogram("http_request_duration_ms", duration, labels);
    metrics.counter("http_requests_total", 1, labels);

    // Alert on tenant-specific degradation
    if (duration > 2000) {
      metrics.counter("http_slow_requests_total", 1, labels);
    }
  });

  next();
}

Más allá de las métricas a nivel de solicitud, rastrea el consumo de recursos por tenant:

interface TenantUsageMetrics {
  tenantId: string;
  period: string; // "2026-03"
  apiCalls: number;
  storageBytes: number;
  bandwidthBytes: number;
  computeMs: number;
  activeUsers: number;
}

class UsageTracker {
  async recordAPICall(tenantId: string, durationMs: number): Promise<void> {
    const period = this.getCurrentPeriod();
    await redis.hincrby(`usage:${tenantId}:${period}`, "apiCalls", 1);
    await redis.hincrby(`usage:${tenantId}:${period}`, "computeMs", durationMs);
  }

  async getUsage(tenantId: string, period: string): Promise<TenantUsageMetrics> {
    const data = await redis.hgetall(`usage:${tenantId}:${period}`);
    return {
      tenantId,
      period,
      apiCalls: parseInt(data.apiCalls ?? "0"),
      storageBytes: parseInt(data.storageBytes ?? "0"),
      bandwidthBytes: parseInt(data.bandwidthBytes ?? "0"),
      computeMs: parseInt(data.computeMs ?? "0"),
      activeUsers: parseInt(data.activeUsers ?? "0"),
    };
  }
}

Estos datos alimentan tres propósitos: facturación (pricing basado en uso), planificación de capacidad (qué tenants están creciendo más rápido) y depuración (la experiencia lenta de este tenant se debe a su volumen de datos o a un problema de la plataforma).

Configura dashboards con desgloses a nivel de tenant. Cuando se dispara una alerta, deberías poder ver en segundos si afecta a un tenant o a todos. Esto cambia tu respuesta a incidentes de "algo está lento" a "las consultas de analytics del Tenant X están lentas porque su dataset creció más allá del umbral donde nuestros índices actuales son efectivos."

Patrones de Integración de Facturación

La facturación en un sistema multi-tenant va más allá de "cobrar a cada tenant mensualmente." Necesitas manejar niveles de plan, componentes basados en uso, pricing por asiento y upgrades y downgrades a mitad de ciclo.

class BillingService {
  private stripe: Stripe;

  async syncPlanChange(tenantId: string, newPlan: PlanTier): Promise<void> {
    const tenant = await db("tenants").where({ id: tenantId }).first();

    // Update Stripe subscription
    const subscription = await this.stripe.subscriptions.retrieve(
      tenant.stripe_subscription_id
    );

    await this.stripe.subscriptions.update(subscription.id, {
      items: [{
        id: subscription.items.data[0].id,
        price: PLAN_PRICE_IDS[newPlan],
      }],
      proration_behavior: "create_prorations",
    });

    // Sync feature flags based on new plan
    await this.syncFeatureFlags(tenantId, newPlan);

    // Update limits
    await this.syncLimits(tenantId, newPlan);

    // Invalidate tenant context cache
    await redis.del(`tenant:${tenantId}:context`);
  }

  private async syncFeatureFlags(tenantId: string, plan: PlanTier): Promise<void> {
    const planFeatures = PLAN_FEATURE_MAP[plan];

    // Disable features not included in new plan
    await db("tenant_features")
      .where({ tenant_id: tenantId })
      .whereNotIn("feature_key", planFeatures)
      .update({ enabled: false });

    // Enable features included in new plan
    for (const feature of planFeatures) {
      await db("tenant_features")
        .insert({ tenant_id: tenantId, feature_key: feature, enabled: true })
        .onConflict(["tenant_id", "feature_key"])
        .merge();
    }
  }
}

Para facturación basada en uso, reportas el uso a Stripe al final de cada período de facturación:

async function reportUsageToStripe(tenantId: string): Promise<void> {
  const tenant = await db("tenants").where({ id: tenantId }).first();
  const usage = await usageTracker.getUsage(tenantId, getCurrentPeriod());

  // Report metered usage for API calls
  await stripe.subscriptionItems.createUsageRecord(
    tenant.stripe_metered_item_id,
    {
      quantity: usage.apiCalls,
      timestamp: Math.floor(Date.now() / 1000),
      action: "set",
    }
  );
}

Ejecuta esto en un cron job al final de cada período de facturación e implementa idempotencia para que ejecutarlo dos veces no genere un doble cobro.

Arquitectura de Marca Blanca

La marca blanca es la capacidad de que los tenants presenten tu plataforma como su propio producto. Esto significa dominios personalizados, branding personalizado, plantillas de email personalizadas y a veces temas de UI personalizados.

La arquitectura tiene dos preocupaciones principales: enrutamiento y theming.

Enrutamiento de Dominio Personalizado

// Tenant domain mapping
interface TenantDomain {
  tenantId: string;
  domain: string;        // "orders.acme-restaurant.com"
  sslStatus: "pending" | "active" | "expired";
  verifiedAt?: Date;
}

async function resolveCustomDomain(hostname: string): Promise<string | null> {
  const mapping = await redis.get(`domain:${hostname}`);
  if (mapping) return mapping;

  const row = await db("tenant_domains")
    .where({ domain: hostname, ssl_status: "active" })
    .first();

  if (row) {
    await redis.set(`domain:${hostname}`, row.tenant_id, "EX", 3600);
    return row.tenant_id;
  }

  return null;
}

En tu middleware de resolución de tenant, verifica los dominios personalizados antes de recurrir a la resolución por subdominio:

export async function tenantMiddleware(req: Request, res: Response, next: NextFunction) {
  // 1. Check JWT claim
  // ... (existing logic)

  // 2. Check custom domain
  const customTenantId = await resolveCustomDomain(req.hostname);
  if (customTenantId) {
    req.tenantId = customTenantId;
    return next();
  }

  // 3. Fall back to subdomain
  // ... (existing logic)
}

Para SSL en dominios personalizados, usa un certificado wildcard para tus subdominios y Let's Encrypt con desafío DNS o HTTP para dominios personalizados. Servicios como Caddy o Cloudflare for SaaS pueden automatizar esto por completo.

Theming y Branding

interface TenantBranding {
  tenantId: string;
  logoUrl: string;
  faviconUrl: string;
  primaryColor: string;
  secondaryColor: string;
  fontFamily?: string;
  customCSS?: string;
  emailFromName: string;
  emailFromAddress: string;
  supportUrl?: string;
  termsUrl?: string;
  privacyUrl?: string;
}

En el frontend, inyecta el branding como propiedades CSS personalizadas:

function applyBranding(branding: TenantBranding) {
  const root = document.documentElement;
  root.style.setProperty("--color-primary", branding.primaryColor);
  root.style.setProperty("--color-secondary", branding.secondaryColor);
  if (branding.fontFamily) {
    root.style.setProperty("--font-family", branding.fontFamily);
  }
}

Si la plataforma se renderiza en el servidor, inyecta el branding en la respuesta HTML inicial para que no haya un flash de contenido sin estilo. Con Next.js, esto significa leer el branding en tu layout raíz y establecer las variables CSS inline en la etiqueta <html> o <body>.

Para plantillas de email, usa el contexto de branding del tenant al renderizar:

async function sendTenantEmail(tenantId: string, template: string, data: unknown) {
  const branding = await getBranding(tenantId);
  const integration = await integrations.getIntegration(tenantId, "email");

  const html = renderEmailTemplate(template, {
    ...data,
    logo: branding.logoUrl,
    primaryColor: branding.primaryColor,
    companyName: branding.emailFromName,
  });

  await emailClient(integration).send({
    from: `${branding.emailFromName} <${branding.emailFromAddress}>`,
    html,
  });
}

El usuario final nunca ve la marca de tu plataforma. Ve el logo, los colores, el dominio y el remitente del tenant. Para ellos, es el producto del tenant.

Consideraciones de Escalamiento

A medida que tu conteo de tenants crece, ciertos patrones dejan de funcionar y necesitan ser reemplazados.

El connection pooling se vuelve crítico. Con bases de datos compartidas, podrías empezar con un pool de conexiones por instancia de aplicación. Pero cuando tienes schema por tenant o base de datos por tenant, la gestión naive de conexiones agotará tus límites de conexión. Usa PgBouncer en modo transacción para bases de datos compartidas e implementa particionamiento de pool de conexiones para aislamiento por schema donde las conexiones se asignan proporcionalmente a la actividad del tenant.

La invalidación de caché se complica. Con una sola instancia de Redis, eliminar el caché de un tenant es simple. Con un caché distribuido, necesitas transmitir eventos de invalidación. Usa Redis Pub/Sub o un bus de eventos dedicado para propagar invalidaciones de caché a través de todas las instancias de aplicación.

Los trabajos en segundo plano necesitan contexto de tenant. Cada trabajo que encoles debe llevar el ID del tenant. El procesador de trabajos debe configurar el mismo contexto de tenant (conexión a base de datos, feature flags, límites) que el middleware HTTP hace. Creo un wrapper withTenantScope para los manejadores de trabajos:

function withTenantScope(handler: (tenantId: string, data: unknown) => Promise<void>) {
  return async (job: Job) => {
    const { tenantId, ...data } = job.data;
    const tenant = await buildTenantContext(tenantId);

    // Set up database context
    await withTenantContext(tenantId, async () => {
      await handler(tenantId, data);
    });
  };
}

// Usage
queue.process("generate-report", withTenantScope(async (tenantId, data) => {
  // This handler runs with full tenant context
  const report = await generateReport(tenantId, data);
  await storeReport(tenantId, report);
}));

La detección de vecinos ruidosos es esencial para infraestructura compartida. Rastrea el tiempo de ejecución de consultas, uso de CPU y memoria por tenant. Cuando la carga de trabajo de un tenant empieza a degradar el pool compartido, tienes tres opciones: limitarlos, migrarlos a un nivel de aislamiento superior u optimizar sus consultas específicas. El monitoreo te da los datos para tomar esa decisión antes de que otros tenants se vean afectados.

Uniendo Todo

Una arquitectura multi-tenant en producción no es un solo patrón — es un stack de decisiones que interactúan entre sí. La estrategia de aislamiento afecta tu gestión de conexiones. Los feature flags afectan la facturación. La marca blanca afecta todo tu pipeline de renderizado frontend. Las integraciones personalizadas afectan tu manejo de errores y monitoreo.

La secuencia que recomiendo para equipos que construyen esto desde cero:

  1. Comienza con todo compartido y consultas con alcance de tenant. Consigue tu product-market fit primero.
  2. Agrega feature flags y control basado en plan tan pronto como tengas una página de precios.
  3. Construye el registro de integraciones cuando tu primer cliente enterprise lo solicite.
  4. Implementa aislamiento por schema cuando un requisito de cumplimiento lo exija.
  5. Agrega marca blanca cuando un socio quiera revender tu plataforma.
  6. Construye las herramientas de migración cuando necesites mover un tenant entre niveles.

Cada capa se construye sobre la anterior. El pipeline de middleware, la interfaz de consultas con alcance y el registro de integraciones son los cimientos que soportan todo lo que está por encima. Hazlos bien, y los patrones avanzados caen en su lugar.

He construido estos patrones en plataformas de restaurantes, SaaS médico y herramientas de gestión de proyectos. Los dominios son diferentes, pero los desafíos de multi-tenencia son notablemente consistentes. La inversión en aislamiento adecuado de tenants, configuración y observabilidad se paga sola en el momento en que consigues tu primer cliente enterprise que pregunta: "Cómo están separados mis datos de los de sus otros clientes?"

Esa pregunta es una señal de compra. La arquitectura que construiste es la respuesta.

DU

Danil Ulmashev

Full Stack Developer

Interesado en trabajar juntos?