Worker Services and the .NET Generic Host
Not everything is a web API. Queue processors, scheduled tasks, file watchers, and event stream consumers all need the same infrastructure as web applications — dependency injection, configuration, logging, graceful shutdown — without the HTTP pipeline. That's where the .NET Generic Host and worker services come in.
The Generic Host
The Generic Host (Microsoft.Extensions.Hosting) provides the foundational infrastructure that ASP.NET Core's WebApplication is built on. When you create a worker service, you get the same capabilities:
var builder = Host.CreateApplicationBuilder(args);
builder.Services.AddHostedService<QueueProcessor>();
var host = builder.Build();
host.Run();
This gives you:
- Dependency injection via
IServiceCollection - Configuration from
appsettings.json, environment variables, command line args - Logging with the standard
ILogger<T>infrastructure - Graceful shutdown via
IHostApplicationLifetimeand cancellation tokens - Options pattern with
IOptions<T>and friends
Creating a worker service
Scaffold one with the template:
dotnet new worker -n MyWorker
The template generates a Worker class that extends BackgroundService:
public class Worker : BackgroundService
{
private readonly ILogger<Worker> _logger;
public Worker(ILogger<Worker> logger)
{
_logger = logger;
}
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
while (!stoppingToken.IsCancellationRequested)
{
_logger.LogInformation("Worker running at: {Time}", DateTimeOffset.Now);
await Task.Delay(1000, stoppingToken);
}
}
}
ExecuteAsync is called once when the host starts. The stoppingToken is triggered when the host receives a shutdown signal (Ctrl+C, SIGTERM, or IHostApplicationLifetime.StopApplication()).
Pattern: queue processor
A common pattern is consuming messages from a queue:
public class OrderProcessor : BackgroundService
{
private readonly IServiceScopeFactory _scopeFactory;
private readonly ILogger<OrderProcessor> _logger;
public OrderProcessor(
IServiceScopeFactory scopeFactory,
ILogger<OrderProcessor> logger)
{
_scopeFactory = scopeFactory;
_logger = logger;
}
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
_logger.LogInformation("Order processor starting");
while (!stoppingToken.IsCancellationRequested)
{
try
{
await using var scope = _scopeFactory.CreateAsyncScope();
var queue = scope.ServiceProvider.GetRequiredService<IOrderQueue>();
var handler = scope.ServiceProvider.GetRequiredService<IOrderHandler>();
var order = await queue.DequeueAsync(stoppingToken);
if (order is not null)
{
await handler.ProcessAsync(order, stoppingToken);
}
}
catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested)
{
// Graceful shutdown, not an error
}
catch (Exception ex)
{
_logger.LogError(ex, "Error processing order");
await Task.Delay(5000, stoppingToken); // Back-off on failure
}
}
}
}
Key points:
- Create a scope per unit of work.
BackgroundServiceis a singleton, so you can't inject scoped services directly. UseIServiceScopeFactoryto create a scope for each message. - Catch
OperationCanceledExceptionfrom the stoppingToken. This is normal shutdown behaviour, not an error. - Back off on failure. Without a delay, a persistent error will spin the CPU and flood your logs.
Pattern: scheduled task
For periodic tasks, use a PeriodicTimer:
public class ReportGenerator : BackgroundService
{
private readonly IServiceScopeFactory _scopeFactory;
private readonly ILogger<ReportGenerator> _logger;
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
using var timer = new PeriodicTimer(TimeSpan.FromHours(1));
// Run immediately on startup, then hourly
do
{
try
{
await using var scope = _scopeFactory.CreateAsyncScope();
var generator = scope.ServiceProvider.GetRequiredService<IReportService>();
await generator.GenerateHourlyReportAsync(stoppingToken);
}
catch (Exception ex) when (ex is not OperationCanceledException)
{
_logger.LogError(ex, "Failed to generate hourly report");
}
}
while (await timer.WaitForNextTickAsync(stoppingToken));
}
public ReportGenerator(
IServiceScopeFactory scopeFactory,
ILogger<ReportGenerator> logger)
{
_scopeFactory = scopeFactory;
_logger = logger;
}
}
PeriodicTimer is preferable to Task.Delay for periodic work because it doesn't drift — it accounts for the execution time of each tick.
Multiple hosted services
Register as many as you need. They start in registration order and stop in reverse order:
builder.Services.AddHostedService<QueueProcessor>();
builder.Services.AddHostedService<ReportGenerator>();
builder.Services.AddHostedService<HealthMonitor>();
// IMPORTANT
ExecuteAsync must not block. If your service does CPU-intensive work, wrap it in Task.Run so it doesn't delay the startup of subsequent hosted services.
Graceful shutdown
The host gives each service a configurable window to shut down:
builder.Services.Configure<HostOptions>(options =>
{
options.ShutdownTimeout = TimeSpan.FromSeconds(30);
});
Within your ExecuteAsync, respect the stoppingToken. If your service is processing a batch, complete the current item and exit rather than abandoning mid-operation.
For cleanup that must happen after ExecuteAsync returns, override StopAsync:
public override async Task StopAsync(CancellationToken cancellationToken)
{
_logger.LogInformation("Draining remaining items...");
await _channel.Writer.TryComplete();
await base.StopAsync(cancellationToken);
}
Running as a system service
Worker services can run as Windows Services or Linux systemd daemons:
// Windows Service
builder.Services.AddWindowsService(options =>
{
options.ServiceName = "OrderProcessor";
});
// Linux systemd
builder.Services.AddSystemd();
Install the corresponding NuGet packages:
dotnet add package Microsoft.Extensions.Hosting.WindowsServices
dotnet add package Microsoft.Extensions.Hosting.Systemd
Both adapt the host to the system service lifecycle, handling start, stop, and status reporting automatically.
Wrapping up
The Generic Host provides everything you need for non-HTTP workloads: DI, configuration, logging, and lifecycle management. BackgroundService handles the most common pattern — a long-running loop with graceful shutdown. Create scopes for each unit of work, respect the cancellation token, and handle errors with backoff. That's all it takes to build production-ready background processors in .NET.