Arquitetura SaaS
7 de janeiro de 2026 · 11 min de leitura
Toda arquitetura de SaaS começa parecendo simples. Um endpoint recebe o request, processa, devolve o response. Funciona bem com 10 clientes. Com 50, aparecem lentidões nos horários de pico. Com 200, a API trava no fechamento do mês. O problema raramente é o banco — é a ausência de três decisões que deveriam ter sido tomadas no dia zero: quando usar fila em vez de chamada síncrona, como limitar carga por tenant, e o que logar antes de o cliente te ligar.
Síncrono vs. assíncrono: a regra que define tudo
A pergunta que determina essa escolha é uma só: o usuário precisa desse resultado para continuar usando o sistema?
Se sim, use chamada síncrona e retorne no response HTTP. Se não, coloque numa fila. Qualquer análise mais elaborada parte daqui.
Exemplos para tirar do abstrato:
- Verificar saldo de créditos antes de executar uma operação → síncrono
- Enviar email de confirmação após cadastro → fila
- Validar cartão no checkout → síncrono
- Gerar PDF de relatório mensal → fila
- Buscar dados para renderizar o dashboard → síncrono
- Sincronizar dados com sistema legado do cliente → fila
O erro mais caro que aparece em produção é chamar API externa de forma síncrona dentro de um endpoint HTTP. O SendGrid trava 3 segundos, o timeout dispara, o usuário recebe 504 — e você fica sem saber o que foi enviado, o que não foi e o que ficou em estado inconsistente.
Com fila, o endpoint responde 202 Accepted em milissegundos. A operação acontece no Worker Service, com retry configurado e log estruturado. Você troca resposta imediata por resiliência — e na maioria dos casos, essa é a troca certa.
O custo real de bloquear threads HTTP
ASP.NET Core mantém um pool de threads para processar requests. Quando uma operação bloqueia — I/O síncrono, Task.Result, chamada externa lenta — a thread fica ocupada até a resposta chegar. Em carga baixa, invisível. Quando o pico chega (campanha de marketing, vencimento de boletos, fechamento de mês), o pool esgota, os requests ficam enfileirados no Kestrel e a latência explode.
async/await correto libera a thread durante o await de I/O. Mas operações pesadas ou com dependência de terceiros precisam sair da camada HTTP completamente. Para isso existe a fila.
SQS com .NET Worker Services: o padrão que funciona em produção
O BackgroundService do .NET é o veículo correto para consumir filas. Pode rodar no mesmo processo da API ou, preferencialmente em produção, em container separado no ECS.
Estrutura base para consumir SQS com long polling:
public class EmailWorker : BackgroundService
{
private readonly IAmazonSQS _sqs;
private readonly IEmailService _email;
private readonly ILogger<EmailWorker> _logger;
private readonly string _queueUrl;
public EmailWorker(IAmazonSQS sqs, IEmailService email,
ILogger<EmailWorker> logger, IConfiguration config)
{
_sqs = sqs;
_email = email;
_logger = logger;
_queueUrl = config["Aws:SqsEmailQueueUrl"]!;
}
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
while (!stoppingToken.IsCancellationRequested)
{
var response = await _sqs.ReceiveMessageAsync(new ReceiveMessageRequest
{
QueueUrl = _queueUrl,
MaxNumberOfMessages = 10,
WaitTimeSeconds = 20 // long polling
}, stoppingToken);
foreach (var message in response.Messages)
{
try
{
var payload = JsonSerializer.Deserialize<EmailMessage>(message.Body)!;
await _email.SendAsync(payload, stoppingToken);
await _sqs.DeleteMessageAsync(_queueUrl, message.ReceiptHandle, stoppingToken);
_logger.LogInformation("Email enviado. TenantId={TenantId} Tipo={Tipo}",
payload.TenantId, payload.Type);
}
catch (Exception ex)
{
// Não deleta — mensagem retorna à fila após visibility timeout
_logger.LogError(ex, "Falha ao processar {MessageId}", message.MessageId);
}
}
}
}
}
Três detalhes que fazem diferença em produção:
Long polling — WaitTimeSeconds = 20 faz o SQS segurar a conexão esperando mensagens antes de devolver resposta vazia. Sem isso, short polling: centenas de chamadas vazias por minuto, custo de API visível na conta da AWS.
Visibility timeout — enquanto o Worker processa, a mensagem fica invisível para outros Workers pelo tempo configurado. Se o container morrer no meio do processamento, a mensagem volta para a fila automaticamente. Configure para pelo menos o dobro do tempo máximo esperado de processamento.
Dead Letter Queue (DLQ) — mensagens que falham maxReceiveCount vezes consecutivas vão para a DLQ automaticamente. Sem DLQ, mensagens problemáticas ficam circulando na fila principal indefinidamente. Com DLQ, você inspeciona o que falhou e reprocessa com lógica específica. Configure maxReceiveCount = 3 como ponto de partida e monitore a DLQ com alarme separado no CloudWatch.
Backpressure: o que acontece quando a fila não aguenta o ritmo
Backpressure é produzir mensagens mais rápido do que você consegue processar. A fila cresce, o lag aumenta, e sem controle você tem um backlog que pode levar horas para zerar — com clientes reclamando de operações que não concluíram.
No SQS, o controle de backpressure fica na concorrência do Worker. Processar mensagens sequencialmente é seguro, mas pode não ser rápido o suficiente. Paralelizar sem limite pode sobrecarregar o banco e os serviços downstream.
A solução é SemaphoreSlim para limitar concorrência máxima:
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
var semaphore = new SemaphoreSlim(initialCount: 5, maxCount: 5);
while (!stoppingToken.IsCancellationRequested)
{
var response = await _sqs.ReceiveMessageAsync(new ReceiveMessageRequest
{
QueueUrl = _queueUrl,
MaxNumberOfMessages = 10,
WaitTimeSeconds = 20
}, stoppingToken);
var tasks = response.Messages.Select(async message =>
{
await semaphore.WaitAsync(stoppingToken);
try
{
await ProcessMessageAsync(message, stoppingToken);
await _sqs.DeleteMessageAsync(_queueUrl, message.ReceiptHandle, stoppingToken);
}
finally
{
semaphore.Release();
}
});
await Task.WhenAll(tasks);
}
}
O SemaphoreSlim(5) garante no máximo 5 mensagens processadas em paralelo, independente do tamanho do batch recebido do SQS. Ajuste o valor conforme o throughput que o banco e os serviços downstream conseguem absorver — comece conservador e aumente baseado em métricas reais.
Além do controle de concorrência, configure um alarme no CloudWatch na métrica ApproximateNumberOfMessagesVisible da fila. Quando ultrapassar um threshold razoável — 500 mensagens, por exemplo — você quer um alerta antes que o backlog vire horas de lag e o cliente perceba o problema antes de você.
Rate limiting por tenant no .NET 8
Rate limiting por tenant resolve dois problemas ao mesmo tempo: isola tenants problemáticos (noisy neighbor problem) e protege a infraestrutura de picos legítimos que o sistema não está dimensionado para absorver.
O .NET 8 tem rate limiting nativo via Microsoft.AspNetCore.RateLimiting — sem Redis, sem biblioteca externa. Para uma instância única (que é o que você vai ter no MVP), a implementação em memória é suficiente. Com múltiplas instâncias em auto-scaling, você vai precisar de backend centralizado como Redis.
// Program.cs
builder.Services.AddRateLimiter(options =>
{
options.RejectionStatusCode = StatusCodes.Status429TooManyRequests;
options.AddPolicy("per-tenant", context =>
{
var tenantId = context.User.FindFirst("tenant_id")?.Value ?? "anonymous";
var plan = context.User.FindFirst("plan")?.Value ?? "free";
var permitLimit = plan switch
{
"enterprise" => 1000,
"growth" => 300,
_ => 60
};
return RateLimitPartition.GetFixedWindowLimiter(
partitionKey: tenantId,
factory: _ => new FixedWindowRateLimiterOptions
{
PermitLimit = permitLimit,
Window = TimeSpan.FromMinutes(1),
QueueLimit = 0
});
});
});
app.UseRateLimiter();
// Aplicar no endpoint
app.MapGet("/api/relatorios",
[EnableRateLimiting("per-tenant")]
async (IRelatorioService service) => await service.ListarAsync())
.RequireAuthorization();
O ponto que a maioria ignora: diferentes planos têm limites diferentes. Um tenant Free não deveria ter o mesmo headroom que um Enterprise. O plano fica no claim do JWT — o rate limiter lê e aplica a política correta sem consultar o banco a cada request.
Quando o limite é atingido, retorne 429 com o header Retry-After preenchido. O cliente sabe quando pode tentar de novo, e você evita que loops de retry automático agravem o problema ao invés de aguardar.
Observabilidade desde o dia zero: Serilog e correlation IDs
Observabilidade não é opcional — é o que permite reconstruir o que aconteceu quando um cliente abre ticket dizendo 'deu um erro às 14h'. Sem ela, você está voando às cegas em produção.
O ponto de partida é structured logging com Serilog. Ao contrário do logging textual concatenado, structured logging produz campos indexáveis: você consegue fazer WHERE TenantId = 'abc' AND Level = 'Error' no CloudWatch Logs Insights. Com string formatada manualmente, só text search — lento, impreciso e inescalável com múltiplos tenants.
// Program.cs — configuração base do Serilog
Log.Logger = new LoggerConfiguration()
.Enrich.FromLogContext()
.Enrich.WithProperty("Application", builder.Environment.ApplicationName)
.Enrich.WithProperty("Environment", builder.Environment.EnvironmentName)
.WriteTo.Console(new JsonFormatter())
.WriteTo.AmazonCloudWatch(new CloudWatchSinkOptions
{
LogGroupName = $"/myapp/{builder.Environment.EnvironmentName}/api",
MinimumLogEventLevel = LogEventLevel.Information,
CreateLogGroup = true
})
.MinimumLevel.Override("Microsoft.AspNetCore", LogEventLevel.Warning)
.MinimumLevel.Override("Microsoft.EntityFrameworkCore", LogEventLevel.Warning)
.CreateLogger();
builder.Host.UseSerilog();
O Correlation ID é o que permite rastrear uma requisição através de múltiplos serviços. O middleware abaixo cria ou propaga o ID, enriquece o log context e devolve no response header:
public class RequestContextMiddleware
{
private readonly RequestDelegate _next;
public RequestContextMiddleware(RequestDelegate next) => _next = next;
public async Task InvokeAsync(HttpContext context)
{
var correlationId = context.Request.Headers["X-Correlation-ID"].FirstOrDefault()
?? Guid.NewGuid().ToString("N");
var tenantId = context.User.FindFirst("tenant_id")?.Value ?? "unauthenticated";
using (LogContext.PushProperty("CorrelationId", correlationId))
using (LogContext.PushProperty("TenantId", tenantId))
{
context.Response.Headers.Append("X-Correlation-ID", correlationId);
await _next(context);
}
}
}
// Registrar em Program.cs, antes do UseRouting
app.UseMiddleware<RequestContextMiddleware>();
Com isso, todo log do sistema — incluindo exceções, EF Core (quando habilitado) e Worker Services que propagam o CorrelationId via headers downstream — carrega os campos CorrelationId e TenantId. Uma query útil no CloudWatch Logs Insights:
fields @timestamp, TenantId, CorrelationId, @message
| filter Level = 'Error' and TenantId = 'tenant-xyz'
| sort @timestamp desc
| limit 50
Isso responde 'o que aconteceu com esse tenant entre 14h e 15h' em segundos, sem SSH em nenhum servidor e sem cruzar dados de tenants diferentes.
Armadilhas do over-engineering no MVP
O lado oposto do problema: o que não fazer no early-stage, mesmo que pareça a decisão arquiteturalmente correta.
Kafka para 20 clientes — Kafka faz sentido com throughput de dezenas de milhares de mensagens por segundo, múltiplos consumer groups e retenção longa de eventos. Para um SaaS com 50 tenants, SQS resolve com menos operação, menos custo e zero overhead de cluster. Kafka no MSK começa em torno de USD 200/mês antes de qualquer workload real. SQS começa em frações de centavo por milhão de requests. Use SQS até ter razão mensurável para mudar.
Event sourcing por princípio — Event sourcing é uma escolha de modelo de dados onde você armazena eventos, não estado. Tem benefícios reais em auditoria, replay e CQRS. Também adiciona complexidade enorme: projeções, snapshots, eventual consistency em todo o domínio. Se não há um requisito específico que só event sourcing resolve, não adicione. Um SaaS B2B comum não precisa disso para funcionar nem para escalar.
Circuit breaker sem observabilidade — Polly tem AddTransientHttpErrorPolicy com circuit breaker. É tentador adicionar porque parece boa prática de resiliência. Mas circuit breaker sem dashboards configurados, sem alerta quando o circuito abre, e sem entendimento do padrão de falha do serviço downstream é complexidade ornamental. Primeiro instrumente tudo que é chamado externamente. Quando o padrão de falha aparecer nos logs, aí você decide se circuit breaker é a solução certa.
Microsserviços desde o dia zero — Microsserviços têm custo de operação real: deploy independente, service discovery, comunicação de rede, versionamento de contrato entre serviços. Com time pequeno, esse overhead mata a velocidade de entrega. Comece com monólito modular — domínios bem separados dentro de um único deploy. Quando um módulo tiver razão objetiva para separar (escala radicalmente diferente, time dedicado, domínio completamente isolado), extrai. Não antes.
O que não pode ser adiado e o que pode esperar
A arquitetura certa para o dia zero não é a arquitetura certa para o mês 18. O que não muda é o critério de decisão — o que muda é a profundidade de implementação.
Não adie — o custo de retrofitar é alto:
- A separação síncrono/assíncrono por tipo de operação. Mudar depois significa identificar todas as chamadas problemáticas, modelar os contratos de mensagem, reescrever a lógica de processamento e garantir idempotência em cada Worker.
- Rate limiting por tenant. Um bug de loop no código do cliente pode derrubar a API inteira para todos os tenants sem isso. Implemente antes do primeiro cliente pagante.
- Serilog com TenantId e CorrelationId. Todo histórico de log sem esses campos é inútil para debug de produção. Implemente antes do primeiro deploy.
Pode esperar até a dor aparecer:
- Backpressure avançado com múltiplos níveis de concorrência: comece com
SemaphoreSlimbásico e ajuste quando o CloudWatch mostrar lag crescente. - OpenTelemetry com distributed tracing completo: necessário quando você tiver mais de um serviço se comunicando via rede. No monólito com Worker Services, logs estruturados com CorrelationId são suficientes.
- Redis para rate limiting distribuído: só quando você escalar para múltiplas instâncias do serviço.
Fila com SQS + Worker Services, rate limiting nativo do .NET 8 com partição por tenant, e Serilog com enrichers de TenantId e CorrelationId — isso é o suficiente para chegar nos primeiros 100 tenants sem incêndio. O resto você adiciona quando a dor aparecer, com dados reais, não por antecipação.
Se você está desenhando a arquitetura de um SaaS e quer evitar as decisões que travam o crescimento lá na frente, fala com a Logik Digital. Analisamos o desenho atual e identificamos onde os gargalos vão aparecer antes de aparecerem em produção.