Skip to main content
backend15 de março de 202616 min de leitura

Padrões de Arquitetura Multi-Tenancy: Além do Básico

Padrões avançados de arquitetura multi-tenant para plataformas SaaS — desde estratégias de isolamento de dados até customização por tenant e escalabilidade.

saasarchitecturemulti-tenancy
Padrões de Arquitetura Multi-Tenancy: Além do Básico

A maioria dos guias de multi-tenancy para em "adicione uma coluna tenant_id e filtre suas consultas." Isso te leva pelos primeiros meses. Então um tenant com 10x mais dados começa a degradar a performance para todo mundo, um prospect enterprise pergunta sobre white-labeling, e um parceiro quer integrar seu próprio processador de pagamentos. De repente, o básico não é mais suficiente.

Este post cobre os padrões que implementei em sistemas multi-tenant em produção que vão além do escopo de consultas. Essas são as decisões arquiteturais que separam um protótipo de uma plataforma.

Padrões Avançados de Isolamento

O modelo de três camadas de banco de dados compartilhado, schema-por-tenant e banco-de-dados-por-tenant é bem compreendido. O que é menos discutido é a abordagem híbrida que a maioria dos sistemas de produção acaba usando.

Isolamento por Camadas Baseado em Segmento de Cliente

Na prática, você raramente escolhe um nível de isolamento e aplica uniformemente. Em vez disso, você organiza seu isolamento em camadas baseado no que cada segmento de clientes precisa e está disposto 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!);
  }
}

A camada compartilhada serve seus clientes self-serve. O isolamento por schema serve clientes mid-market que precisam de documentação de compliance. Bancos de dados dedicados servem contas enterprise que requerem separação física de dados.

A restrição-chave de design: seu código de aplicação não deve saber ou se importar em qual camada um tenant está. A resolução de conexão acontece no middleware, e tudo downstream usa a mesma interface de consulta. Se um handler de rota precisa verificar o nível de isolamento para decidir como consultar dados, sua abstração está vazando.

Row-Level Security como Rede de Segurança

O Row-Level Security do PostgreSQL é a funcionalidade mais subutilizada em arquiteturas multi-tenant. Mesmo com escopo disciplinado de consultas na sua camada de aplicação, o RLS fornece uma garantia no nível do banco de dados de que um tenant não pode acessar dados de outro 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;

No nível da conexão, você define o contexto do tenant antes de executar qualquer 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();
  }
}

Com isso implementado, mesmo se seu código de aplicação tiver um bug que omita a cláusula WHERE tenant_id = ?, o próprio banco de dados filtrará os resultados. Isso já capturou bugs reais em produção para mim. Um desenvolvedor escreveu uma consulta de relatório que fazia join entre tabelas e esqueceu o filtro de tenant em uma delas — o RLS silenciosamente retornou apenas as linhas corretas em vez de vazar dados.

O FORCE ROW LEVEL SECURITY é importante. Sem ele, proprietários de tabela (tipicamente a role que sua aplicação usa) byppassam as políticas RLS. Com ele, as políticas se aplicam a todos.

Pipeline de Middleware Ciente do Tenant

O middleware de resolução de tenant que cobri no meu post anterior sobre backends multi-tenant lida com o básico: extrair tenant do subdomínio ou JWT, anexar ao request. Mas um sistema de produção precisa de um pipeline de middleware mais rico que constrói um contexto completo do 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;
}

O middleware então anexa este contexto completo ao request:

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);
  }
}

Todo handler downstream agora tem acesso ao contexto completo do tenant sem fazer chamadas adicionais ao banco de dados. O cache de cinco minutos significa que uma mudança de plano propaga rapidamente, mas você não está consultando o banco a cada request para verificar se uma feature está habilitada.

Configuração Dinâmica por Tenant

Além de feature flags, tenants frequentemente precisam de comportamento configurável. Uma plataforma de restaurantes pode deixar cada restaurante definir seus próprios horários de corte de pedidos, taxas de impostos, zonas de entrega e preferências de notificação. Uma ferramenta de gerenciamento de projetos pode deixar cada workspace configurar campos customizados, estágios de workflow e regras de notificação.

O padrão que uso separa a configuração em uma coluna 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>;

As configurações são armazenadas como JSONB no PostgreSQL, que te dá capacidades de indexação e consulta enquanto mantém o schema flexível:

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;
}

O schema Zod serve duplo propósito: ele valida configurações na escrita e fornece defaults na leitura. Se você adicionar uma nova configuração, tenants existentes automaticamente recebem o valor padrão sem uma migração de dados.

Feature Flags por Tenant

Flags booleanas simples não são suficientes para uma plataforma madura. Você precisa de suporte para rollouts percentuais, controle baseado em plano e 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);
  }
}

O hash determinístico é importante para rollouts. Um tenant ou vê consistentemente a feature ou não — você não quer que eles vejam em uma requisição e não na próxima. O hash é calculado a partir do ID do tenant e da chave da feature, então features diferentes podem ser distribuídas para subconjuntos diferentes de tenants.

Em handlers de rota, a verificação é limpa:

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));
});

Integrações Específicas por Tenant

Tenants enterprise vão querer conectar suas próprias ferramentas: sua própria conta Stripe para processamento de pagamentos, sua própria conta SendGrid para emails, seu próprio bucket S3 para armazenamento de arquivos. Sua plataforma precisa suportar isso sem virar um pesadelo de configuração.

O padrão é um registro de integrações que resolve as credenciais corretas 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);
  }
}

A camada de serviço então usa o registro para obter o cliente correto:

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}`);
    }
  }
}

Isso significa que um tenant pode usar a conta SendGrid compartilhada da plataforma enquanto outro usa sua própria instância Amazon SES com seu próprio domínio e reputação. O resto do código da sua aplicação não sabe ou se importa — ele chama emailService.sendEmail() e a camada de integração lida com o roteamento.

A segurança de credenciais é inegociável aqui. Chaves de API fornecidas por tenants devem ser criptografadas em repouso, preferencialmente com chaves de criptografia por tenant. Use algo como AWS KMS ou HashiCorp Vault para gerenciar isso — não implemente seu próprio gerenciamento de chaves de criptografia.

Migração de Dados Entre Níveis de Isolamento

Um dos desafios operacionais mais complicados em sistemas multi-tenant é migrar um tenant de um nível de isolamento para outro. Um tenant em crescimento pode mover de tabelas compartilhadas para seu próprio schema. Um acordo enterprise pode exigir migrar um tenant para um banco de dados dedicado.

A migração precisa ser zero-downtime, o que significa que você não pode simplesmente fazer dump e restore. Aqui está o padrão 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;
    }
  }
}

O passo de Change Data Capture é crítico. Entre copiar os dados iniciais e realizar a troca, novas escritas estão acontecendo nas tabelas compartilhadas. O CDC captura essas escritas e as reproduz no novo schema para que nada seja perdido.

Na prática, uso replicação lógica do PostgreSQL para isso. Você cria uma publicação nas tabelas de origem filtrada por tenant_id, e uma assinatura no schema de destino. Uma vez que o lag de replicação está próximo de zero, você realiza a troca.

O caminho de rollback é igualmente importante. Se algo falhar durante a migração, você precisa poder desfazer tudo de forma limpa. Isso significa manter os dados de origem intactos até que a migração seja verificada e o tenant tenha operado no novo schema por um período de confiança (tipicamente espero 48 horas antes de limpar os dados de origem).

Monitoramento e Observabilidade por Tenant

Em um sistema multi-tenant, "a API está lenta" não é acionável. Você precisa saber qual tenant é afetado, quais consultas estão lentas e se o problema é específico do tenant (vizinho barulhento, dataset grande) ou da plataforma toda.

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();
}

Além de métricas no nível de requisição, rastreie o 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"),
    };
  }
}

Esses dados alimentam três propósitos: faturamento (preços baseados em uso), planejamento de capacidade (quais tenants estão crescendo mais rápido) e depuração (a experiência lenta deste tenant é devido ao volume de dados ou um problema da plataforma).

Configure dashboards com breakdowns por tenant. Quando um alerta disparar, você deve conseguir ver em segundos se afeta um tenant ou todos. Isso muda sua resposta a incidentes de "algo está lento" para "as consultas de analytics do Tenant X estão lentas porque o dataset deles cresceu além do limite onde nossos índices atuais são eficazes."

Padrões de Integração de Faturamento

Faturamento em um sistema multi-tenant vai além de "cobrar cada tenant mensalmente." Você precisa lidar com tiers de plano, componentes baseados em uso, preços por assento, e upgrades e downgrades no meio do 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 faturamento baseado em uso, você reporta o uso ao Stripe no final de cada período de faturamento:

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",
    }
  );
}

Execute isso em um cron job no final de cada período de faturamento e implemente idempotência para que executar duas vezes não cobre em dobro.

Arquitetura de White-Labeling

White-labeling é a capacidade dos tenants apresentarem sua plataforma como seu próprio produto. Isso significa domínios customizados, branding customizado, templates de email customizados e às vezes temas de UI customizados.

A arquitetura tem duas preocupações principais: roteamento e tematização.

Roteamento de Domínio Customizado

// 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;
}

No seu middleware de resolução de tenant, verifique domínios customizados antes de fazer fallback para resolução por subdomínio:

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 em domínios customizados, use um certificado wildcard para seus subdomínios e Let's Encrypt com desafio DNS ou HTTP para domínios customizados. Serviços como Caddy ou Cloudflare for SaaS podem automatizar isso completamente.

Tematização e 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;
}

No frontend, injete o branding como propriedades customizadas CSS:

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);
  }
}

Se a plataforma é renderizada no servidor, injete o branding na resposta HTML inicial para que não haja flash de conteúdo sem estilo. Com Next.js, isso significa ler o branding no seu root layout e definir as variáveis CSS inline na tag <html> ou <body>.

Para templates de email, use o contexto de branding do tenant ao 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,
  });
}

O usuário final nunca vê a marca da sua plataforma. Eles veem o logo, cores, domínio e endereço de remetente do tenant. Para eles, é o produto do tenant.

Considerações de Escalabilidade

Conforme seu número de tenants cresce, certos padrões param de funcionar e precisam ser substituídos.

O pooling de conexões se torna crítico. Com bancos de dados compartilhados, você pode começar com um pool de conexões por instância da aplicação. Mas quando você tem schema-por-tenant ou banco-por-tenant, o gerenciamento ingênuo de conexões vai esgotar seus limites de conexão. Use PgBouncer em modo de transação para bancos compartilhados, e implemente particionamento de pool de conexões para isolamento por schema onde as conexões são alocadas proporcionalmente à atividade do tenant.

A invalidação de cache fica mais difícil. Com uma única instância Redis, deletar o cache de um tenant é simples. Com cache distribuído, você precisa transmitir eventos de invalidação. Use Redis Pub/Sub ou um barramento de eventos dedicado para propagar invalidações de cache em todas as instâncias da aplicação.

Jobs em background precisam de contexto de tenant. Todo job que você enfileirar deve carregar o ID do tenant. O processador de jobs deve configurar o mesmo contexto de tenant (conexão de banco, feature flags, limites) que o middleware HTTP faz. Eu crio um wrapper withTenantScope para handlers de jobs:

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);
}));

A detecção de vizinho barulhento é essencial para infraestrutura compartilhada. Rastreie tempo de execução de consultas, uso de CPU e memória por tenant. Quando a carga de trabalho de um tenant começa a degradar o pool compartilhado, você tem três opções: throttle, migrar para uma camada de isolamento superior ou otimizar suas consultas específicas. O monitoramento te dá os dados para tomar essa decisão antes que outros tenants sejam afetados.

Juntando Tudo

Uma arquitetura multi-tenant de produção não é um único padrão — é uma pilha de decisões que interagem entre si. A estratégia de isolamento afeta seu gerenciamento de conexões. Feature flags afetam o faturamento. White-labeling afeta todo o pipeline de renderização frontend. Integrações customizadas afetam seu tratamento de erros e monitoramento.

A sequência que recomendo para times construindo isso do zero:

  1. Comece com tudo compartilhado e consultas com escopo de tenant. Encontre seu product-market fit primeiro.
  2. Adicione feature flags e controle baseado em plano assim que tiver uma página de preços.
  3. Construa o registro de integrações quando seu primeiro cliente enterprise pedir.
  4. Implemente isolamento por schema quando um requisito de compliance exigir.
  5. Adicione white-labeling quando um parceiro quiser revender sua plataforma.
  6. Construa o ferramental de migração quando precisar mover um tenant entre camadas.

Cada camada constrói sobre a anterior. O pipeline de middleware, a interface de consulta com escopo e o registro de integrações são fundações que suportam tudo acima deles. Acerte esses, e os padrões avançados se encaixam.

Eu construí esses padrões em plataformas de restaurantes, SaaS médico e ferramentas de gerenciamento de projetos. Os domínios são diferentes, mas os desafios de multi-tenancy são notavelmente consistentes. O investimento em isolamento, configuração e observabilidade adequados por tenant se paga no momento em que você fecha seu primeiro cliente enterprise que pergunta: "Como meus dados são separados dos seus outros clientes?"

Essa pergunta é um sinal de compra. A arquitetura que você construiu é a resposta.

DU

Danil Ulmashev

Full Stack Developer

Interesse em trabalhar juntos?