BackgroundService e Worker Service: trabalho também acontece longe do request

Boa parte do raciocínio em API gira em torno do mesmo ciclo: chega um request, alguma coisa processa, sai uma resposta. É um modelo fácil de pensar, mas nem todo trabalho cabe nele. Gerar um relatório grande, reprocessar pendências a cada alguns minutos, consumir uma fila continuamente: nada disso quer ficar preso ao tempo de vida de uma requisição HTTP.

O que resolve isso no .NET

IHostedService é a interface que descreve um serviço com ciclo de vida próprio, iniciado junto com a aplicação e encerrado junto com ela, independente de qualquer request. Ela expõe dois métodos, StartAsync e StopAsync, chamados automaticamente pelo host.

BackgroundService é uma classe abstrata que já implementa IHostedService e reduz o trabalho a um único método para sobrescrever: ExecuteAsync. É o ponto de entrada onde o processamento contínuo realmente acontece, e é registrado com AddHostedService<T>() no IServiceCollection.

public class RelatorioWorker(IServiceScopeFactory scopeFactory) : BackgroundService
{
    protected override async Task ExecuteAsync(CancellationToken stoppingToken)
    {
        while (!stoppingToken.IsCancellationRequested)
        {
            using var scope = scopeFactory.CreateScope();
            var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();

            await ProcessarPendenciasAsync(db, stoppingToken);

            await Task.Delay(TimeSpan.FromMinutes(5), stoppingToken);
        }
    }
}

O CancellationToken não é decoração

O stoppingToken recebido em ExecuteAsync é sinalizado quando a aplicação começa a encerrar. Ignorá-lo, seja num laço while (true) sem verificação, seja numa chamada de Task.Delay sem passar o token, significa que o worker só some quando o host força o encerramento, não quando é avisado educadamente para parar.

Isso importa porque o host não espera para sempre. HostOptions.ShutdownTimeout (5 segundos por padrão) define quanto tempo StopAsync tem para terminar antes do processo ser encerrado de qualquer jeito. Um worker que não respeita o token pode ser interrompido no meio de uma operação, o que é bem diferente de ter a chance de finalizar com cuidado.

O problema que pega gente de primeira viagem: escopo de DI

BackgroundService normalmente é registrado como singleton, porque existe um único worker rodando durante toda a vida da aplicação. O problema aparece quando esse worker precisa de um serviço scoped, como um DbContext do Entity Framework: injetar AppDbContext direto no construtor do worker tentaria amarrar um objeto de vida curta a um serviço de vida longa, e isso não funciona.

A solução é IServiceScopeFactory. Em vez de depender do DbContext diretamente, o worker recebe a fábrica de escopos e cria um novo escopo a cada iteração do laço, como no exemplo acima. Isso garante que cada execução tenha sua própria instância do DbContext, do jeito que ele foi desenhado para ser usado.

Onde isso deixa de ser suficiente

BackgroundService funciona bem para tarefas periódicas, processamento em lote agendado dentro do próprio processo, ou consumo simples de uma fila quando volume e requisitos de confiabilidade ainda são modestos. Ele roda dentro do mesmo processo da aplicação, então se a aplicação reinicia, escala horizontalmente ou cai, o worker vai junto, sem coordenação entre instâncias.

Quando aparece necessidade de reprocessamento garantido, dead-letter queue, execução distribuída entre múltiplas instâncias sem duplicar trabalho, ou agendamento mais sofisticado, a resposta normalmente deixa de ser “mais um BackgroundService” e passa a ser uma fila de verdade ou um orquestrador dedicado. BackgroundService continua sendo o ponto de partida certo até que essa necessidade apareça, não a solução definitiva para qualquer processamento assíncrono.