Abra um Worker Service de seis meses de idade que ninguém estruturou direito e você vai encontrar a mesma cena: uma classe Worker de duzentas linhas, com lógica de banco, regra de negócio e tratamento de erro, toda enrolada dentro de um while. Tente escrever um teste unitário pra isso. Não dá — você teria que subir o host inteiro.
A diferença entre um worker que vira essa bola de neve e um que se mantém manutenível está em uma decisão simples de design: manter a classe Worker fina e empurrar a lógica para um JobProcessor injetável. Neste post você vai ver como essa separação destrava testabilidade e evolução — e por que ela é uma questão de arquitetura, não de configuração.
O instinto errado: colocar a lógica no worker
Quando você cria um Worker Service (dotnet new worker), o template te dá um Worker.cs com um loop pronto. É tentador começar a escrever ali mesmo: pega o job, valida, processa, salva, repete. Tudo dentro do ExecuteAsync.
O problema é que o worker passa a ter duas responsabilidades misturadas:
- Orquestrar — manter o loop de pé, controlar o intervalo, respeitar o cancellation.
- Executar — a regra concreta de processar cada job.
Misturar essas duas coisas é o que torna o worker impossível de testar e difícil de evoluir. Cada mudança na regra de negócio mexe na mesma classe que controla o ciclo de vida do processo.
A regra de ouro: o worker deve ser fino
A mentalidade certa é: a classe Worker tem uma responsabilidade muito específica — orquestrar a execução, recuperar o trabalho e delegar o processamento. Nada mais.
Toda a lógica de "o que fazer com o job" mora em outro lugar: um componente separado, injetável, que você registra no DI. Vamos chamá-lo de JobProcessor.
Primeiro, extraia a lógica para a sua própria classe:
public class JobProcessor
{
public Task ProcessNextJob()
{
// toda a regra de processamento vive aqui
return Task.CompletedTask;
}
}
Registre no program.cs como scoped — porque, na prática, esse processador vai depender de coisas como um DbContext:
builder.Services.AddScoped<JobProcessor>();
E deixe o worker apenas coordenar e delegar:
public class Worker(IServiceScopeFactory serviceScopeFactory) : BackgroundService
{
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
while (!stoppingToken.IsCancellationRequested)
{
using var scope = serviceScopeFactory.CreateScope();
var jobProcessor = scope.ServiceProvider.GetRequiredService<JobProcessor>();
await jobProcessor.ProcessNextJob();
await Task.Delay(2000, stoppingToken);
}
}
}
Repare no que o worker faz agora: ele vai no nível mais alto de abstração — "pega o próximo job e faz algo com ele" — e só. É tudo o que essa classe precisa saber. Esse é o padrão que se usa o tempo todo em produção: o worker puxa trabalho de uma fila ou event bus, e delega o resto.
Por que essa separação destrava os testes
Aqui está o ganho concreto. Com a lógica isolada no JobProcessor, você consegue testá-lo de forma independente do host.
O JobProcessor é só uma classe comum, com dependências injetadas. Em um teste unitário, você instancia ela com mocks/fakes das dependências, chama ProcessNextJob() e verifica o comportamento — sem subir o generic host, sem loop, sem Task.Delay, sem cancellation token.
Enquanto isso, o Worker fica tão fino que quase não há o que testar nele: ele só orquestra. A complexidade que realmente merece cobertura de teste — a regra de negócio — está num lugar onde testar é trivial.
É exatamente isso que a dependency injection te dá: a capacidade de separar responsabilidades e testar cada componente isoladamente. Como bônus, a mesma lógica do JobProcessor pode ser reutilizada em outros contextos, porque ela não está presa ao loop do worker.
A nuance: não deixe o worker engordar de novo
Conforme o sistema cresce, é fácil o worker começar a inchar de novo — uma condicional aqui, um if (job != null) ali, um contador de jobs pendentes acolá. Antes que perceba, a classe que devia ser fina virou o centro da lógica outra vez.
Fique atento a esse drift. Sempre que notar o worker crescendo, pergunte: isso é orquestração ou execução? Se for execução, o lugar é o JobProcessor. O worker coordena; outro componente realiza o trabalho. Manter essa fronteira nítida é o que torna o sistema mais fácil de testar, estender e evoluir ao longo do tempo.
Conclusão
Um Worker Service bem feito não é sobre o loop — é sobre quem faz o quê. Worker fino que orquestra, JobProcessor que executa: essa única decisão de design separa os workers testáveis e duráveis dos que viram dívida técnica.
Da próxima vez que for escrever lógica dentro de um ExecuteAsync, pare e pergunte se aquilo não pertence a um processador injetável. Seu eu do futuro — e a sua suíte de testes — vão agradecer. No próximo post, vamos olhar para dentro desse loop e as armadilhas de projetá-lo para produção.
Top comments (0)