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 pollingWaitTimeSeconds = 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 SemaphoreSlim bá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.

Conversar com a Logik Digital

Tags: arquitetura saas escalável, Arquitetura SaaS, TOFU