Multi-tenancy é a capacidade de uma mesma aplicação servir múltiplos clientes (tenants) com isolamento de dados. É o modelo padrão de SaaS: uma instalação, muitos clientes. A escolha da estratégia de isolamento define a segurança, performance, custo operacional e complexidade de manutenção da sua plataforma.
As três estratégias de isolamento
Cada estratégia tem trade-offs claros entre custo, isolamento e complexidade:
1. Banco separado por tenant — máximo isolamento, custo operacional alto, difícil de gerenciar com muitos tenants. Ideal para tenants grandes com requisitos regulatórios rigorosos (LGPD, HIPAA, SOC2).
2. Schema separado por tenant — isolamento forte com custo moderado. Um banco, mas cada tenant tem seu próprio schema PostgreSQL (tenant_abc.pedidos, tenant_xyz.pedidos). Suporta centenas de tenants sem problema. É o ponto de equilíbrio mais comum em SaaS B2B.
3. Banco compartilhado com tenant_id — menor custo, menor isolamento, mais simples. Todas as tabelas têm uma coluna tenant_id e Row-Level Security (RLS) no banco garante o isolamento. Ideal para SaaS com muitos tenants pequenos (B2C, freemium).
Resolução de tenant: identificar quem está chamando
Antes de qualquer coisa, a aplicação precisa saber qual tenant está fazendo a request:
// Interface central de contexto de tenant: public interface ITenantContext { Guid TenantId { get; } string TenantSlug { get; } string? DatabaseSchema { get; } // para schema-per-tenant }// Resolver por subdomínio (tenant1.seuapp.com.br): public class SubdomainTenantResolver : ITenantResolver { private readonly ITenantRepository _tenants;
public async Task<TenantInfo?> ResolveAsync(HttpContext context) { var host = context.Request.Host.Host; // "tenant1.seuapp.com.br" var slug = host.Split('.')[0]; // "tenant1" if (slug == "www" || slug == "api") return null; return await _tenants.GetBySlugAsync(slug); }}
// Resolver por JWT claim (SaaS B2B com login centralizado): public class JwtTenantResolver : ITenantResolver { public Task<TenantInfo?> ResolveAsync(HttpContext context) { var tenantId = context.User.FindFirst(“tenant_id”)?.Value; if (!Guid.TryParse(tenantId, out var id)) return Task.FromResult<TenantInfo?>(null);
return Task.FromResult<TenantInfo?>(new TenantInfo { TenantId = id }); }}
// Middleware que resolve o tenant e injeta no contexto: public class TenantMiddleware { private readonly RequestDelegate _next;
public async Task InvokeAsync(HttpContext context, ITenantResolver resolver, ITenantContext tenantCtx) { var tenant = await resolver.ResolveAsync(context); if (tenant == null) { context.Response.StatusCode = StatusCodes.Status400BadRequest; await context.Response.WriteAsync("Tenant não identificado."); return; } // Injeta o tenant no contexto via AsyncLocal (thread-safe, per-request) tenantCtx.SetCurrent(tenant); await _next(context); }
}
Estratégia 1: Schema por tenant no PostgreSQL
Cada tenant tem seu próprio schema. A aplicação troca o search_path do EF Core por request:
// DbContext multi-tenant com troca dinâmica de schema: public class TenantDbContext : DbContext { private readonly ITenantContext _tenant;public TenantDbContext(DbContextOptions<TenantDbContext> options, ITenantContext tenant) : base(options) { _tenant = tenant; } public DbSet<Pedido> Pedidos { get; set; } public DbSet<Cliente> Clientes { get; set; } public DbSet<Produto> Produtos { get; set; } protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder) { // Define o search_path do PostgreSQL para o schema do tenant // Isso faz com que todas as queries usem as tabelas do tenant correto if (!string.IsNullOrEmpty(_tenant.DatabaseSchema)) { // Intercepta a conexão para definir o schema: optionsBuilder.AddInterceptors(new SetSearchPathInterceptor(_tenant.DatabaseSchema)); } } protected override void OnModelCreating(ModelBuilder modelBuilder) { modelBuilder.HasDefaultSchema(_tenant.DatabaseSchema ?? "public"); // As entidades herdam o schema automaticamente }}
// Interceptor que executa SET search_path na abertura da conexão: public class SetSearchPathInterceptor : DbConnectionInterceptor { private readonly string _schema;
public SetSearchPathInterceptor(string schema) => _schema = schema; public override async Task ConnectionOpenedAsync( DbConnection connection, ConnectionEndEventData eventData, CancellationToken ct) { using var cmd = connection.CreateCommand(); cmd.CommandText = $"SET search_path TO {_schema}, public"; await cmd.ExecuteNonQueryAsync(ct); }
}
Provisionamento automático de schema para novo tenant
public class TenantProvisioningService { private readonly IDbConnection _adminConnection; private readonly ILogger<TenantProvisioningService> _logger;public async Task ProvisionarTenantAsync(Guid tenantId, string slug) { var schema = $"tenant_{slug.Replace("-", "_")}"; _logger.LogInformation("Provisionando schema {Schema} para tenant {TenantId}", schema, tenantId); // 1. Cria o schema await _adminConnection.ExecuteAsync($"CREATE SCHEMA IF NOT EXISTS {schema}"); // 2. Aplica as migrations nesse schema específico // (usando EF Core com o schema do novo tenant) var optionsBuilder = new DbContextOptionsBuilder<TenantDbContext>(); optionsBuilder.UseNpgsql(_adminConnectionString); var fakeTenant = new TenantInfo { TenantId = tenantId, DatabaseSchema = schema }; using var ctx = new TenantDbContext(optionsBuilder.Options, new StaticTenantContext(fakeTenant)); await ctx.Database.MigrateAsync(); // 3. Registra o tenant no catálogo central await _adminConnection.ExecuteAsync( "INSERT INTO catalogo.tenants (id, slug, schema, criado_em) VALUES (@Id, @Slug, @Schema, @CriadoEm)", new { Id = tenantId, Slug = slug, Schema = schema, CriadoEm = DateTime.UtcNow }); _logger.LogInformation("Tenant {Slug} provisionado com sucesso", slug); }
}
Estratégia 2: Banco compartilhado com Row-Level Security (PostgreSQL)
RLS garante no nível do banco que cada conexão só acessa os dados do seu tenant — mesmo que o código cometa um bug de filtragem:
-- No PostgreSQL: habilitar RLS nas tabelas ALTER TABLE pedidos ENABLE ROW LEVEL SECURITY; ALTER TABLE clientes ENABLE ROW LEVEL SECURITY; ALTER TABLE produtos ENABLE ROW LEVEL SECURITY;— Policy: cada usuário da aplicação só vê seus dados CREATE POLICY tenant_isolation ON pedidos USING (tenant_id = current_setting(‘app.current_tenant_id’)::uuid);
CREATE POLICY tenant_isolation ON clientes USING (tenant_id = current_setting(‘app.current_tenant_id’)::uuid);
— Usuário da aplicação (não é superuser, então RLS é aplicado) CREATE USER app_user WITH PASSWORD ‘senha_segura’; GRANT SELECT, INSERT, UPDATE, DELETE ON ALL TABLES IN SCHEMA public TO app_user; — Superusers bypassam RLS — use um role normal para a aplicação!
// No EF Core: define o setting de tenant na abertura da conexão
public class RlsInterceptor : DbConnectionInterceptor
{
private readonly ITenantContext _tenant;
public RlsInterceptor(ITenantContext tenant) => _tenant = tenant;
public override async Task ConnectionOpenedAsync(
DbConnection connection, ConnectionEndEventData eventData, CancellationToken ct)
{
// Define a variável de sessão que o RLS usa
using var cmd = connection.CreateCommand();
cmd.CommandText = "SELECT set_config('app.current_tenant_id', @TenantId, false)";
cmd.Parameters.Add(new NpgsqlParameter("TenantId", _tenant.TenantId.ToString()));
await cmd.ExecuteNonQueryAsync(ct);
}
}
// DbContext com RLS:
public class SharedDbContext : DbContext
{
private readonly ITenantContext _tenant;
protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder)
{
optionsBuilder.AddInterceptors(new RlsInterceptor(_tenant));
}
// Filtragem adicional no EF Core como segunda camada de segurança:
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
modelBuilder.Entity<Pedido>()
.HasQueryFilter(p => p.TenantId == _tenant.TenantId);
modelBuilder.Entity<Cliente>()
.HasQueryFilter(c => c.TenantId == _tenant.TenantId);
}
}
Migrations em multi-tenancy: schema e shared
// Para schema-per-tenant: um script de migration por schema // Execute migrations em paralelo para múltiplos tenants: public class MultiTenantMigrationService { private readonly ITenantRepository _tenants;public async Task MigrarTodosTenantsAsync() { var todos = await _tenants.ListarTodosAsync(); // Migrar em paralelo (controle de concorrência com SemaphoreSlim) var semaphore = new SemaphoreSlim(5); // max 5 tenants simultâneos await Parallel.ForEachAsync(todos, async (tenant, ct) => { await semaphore.WaitAsync(ct); try { using var ctx = CreateContextForTenant(tenant); await ctx.Database.MigrateAsync(ct); _logger.LogInformation("Tenant {Slug} migrado", tenant.Slug); } catch (Exception ex) { _logger.LogError(ex, "Falha ao migrar tenant {Slug}", tenant.Slug); } finally { semaphore.Release(); } }); }
}
Cache por tenant: isolamento no Redis
// Prefix de cache sempre inclui o tenant ID para evitar vazamento de dados entre tenants: public class TenantAwareCache { private readonly IDistributedCache _cache; private readonly ITenantContext _tenant;public async Task<T?> GetAsync<T>(string key, CancellationToken ct = default) where T : class { // Chave com namespace por tenant: "tenant:{id}:{key}" var tenantKey = $"tenant:{_tenant.TenantId}:{key}"; var cached = await _cache.GetStringAsync(tenantKey, ct); return cached != null ? JsonSerializer.Deserialize<T>(cached) : null; } public async Task SetAsync<T>(string key, T value, TimeSpan? ttl = null, CancellationToken ct = default) { var tenantKey = $"tenant:{_tenant.TenantId}:{key}"; await _cache.SetStringAsync( tenantKey, JsonSerializer.Serialize(value), new DistributedCacheEntryOptions { AbsoluteExpirationRelativeToNow = ttl ?? TimeSpan.FromMinutes(10) }, ct); } // Invalida todo o cache de um tenant (ex: na troca de plano): public async Task InvalidarTudoDoTenantAsync() { // Requer StackExchange.Redis diretamente para scan por prefixo var pattern = $"NeryxSaaS:tenant:{_tenant.TenantId}:*"; // ... (usando IServer.Keys como mostrado no artigo de Redis) }
}
Plano de dados por tenant (quotas e limites)
// Aplicar limites de uso por plano do tenant: public class TenantPlanMiddleware { private readonly RequestDelegate _next;public async Task InvokeAsync(HttpContext context, ITenantContext tenant, ITenantPlanService planService) { // Verifica uso do mês atual var uso = await planService.GetUsoAtualAsync(tenant.TenantId); var plano = await planService.GetPlanoAsync(tenant.TenantId); if (uso.ApiCalls >= plano.LimiteApiCallsMes) { context.Response.StatusCode = StatusCodes.Status429TooManyRequests; await context.Response.WriteAsJsonAsync(new { erro = "Limite de API do plano atingido", limite = plano.LimiteApiCallsMes, uso_atual = uso.ApiCalls, reset_em = uso.ResetEm.ToString("yyyy-MM-dd") }); return; } // Registra a chamada (async, sem bloquear o request) _ = planService.RegistrarChamadaAsync(tenant.TenantId); await _next(context); }
}
Qual estratégia escolher?
Para SaaS B2B com menos de 500 tenants e dados sensíveis por cliente, use schema-per-tenant no PostgreSQL — boa combinação de isolamento e custo. Para SaaS B2C ou freemium com milhares de tenants, banco compartilhado com RLS é mais prático. Banco separado por tenant só faz sentido para enterprise com contratos com cláusulas de isolamento físico obrigatório.
Conclusão
Multi-tenancy bem implementado é invisível para os usuários finais e robusto o suficiente para nunca misturar dados entre clientes. A escolha entre schema-per-tenant e banco compartilhado com RLS depende do perfil dos seus tenants e dos requisitos de compliance. O padrão de contexto de tenant injetado via middleware funciona para as duas abordagens.
Se você está construindo uma plataforma SaaS ou migrando de uma aplicação single-tenant para multi-tenant, a Neryx tem experiência com essas arquiteturas em produção. Consultoria inicial gratuita.
Leitura complementar: