.NET & C# Development · Lesson 77 of 92
Worker Services — Standalone Background Processors
IHostedService vs BackgroundService
IHostedService is the raw interface — you implement StartAsync and StopAsync yourself. BackgroundService is an abstract class that implements IHostedService and gives you a single ExecuteAsync method to override. Use BackgroundService unless you need fine-grained control over startup/shutdown sequencing.
// Raw IHostedService — for simple startup/shutdown tasks
public class CacheWarmupService(ICache cache) : IHostedService
{
public async Task StartAsync(CancellationToken cancellationToken)
{
await cache.WarmupAsync(cancellationToken);
}
public Task StopAsync(CancellationToken cancellationToken)
=> Task.CompletedTask;
}BackgroundService Loop Pattern
public class OutboxProcessorService(
IServiceScopeFactory scopeFactory,
ILogger<OutboxProcessorService> logger) : BackgroundService
{
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
logger.LogInformation("Outbox processor starting");
while (!stoppingToken.IsCancellationRequested)
{
try
{
await ProcessOutboxAsync(stoppingToken);
}
catch (OperationCanceledException)
{
// Normal shutdown — don't log as error
break;
}
catch (Exception ex)
{
logger.LogError(ex, "Outbox processing failed, will retry");
}
await Task.Delay(TimeSpan.FromSeconds(5), stoppingToken);
}
logger.LogInformation("Outbox processor stopped");
}
private async Task ProcessOutboxAsync(CancellationToken ct)
{
await using var scope = scopeFactory.CreateAsyncScope();
var processor = scope.ServiceProvider.GetRequiredService<IOutboxProcessor>();
await processor.ProcessPendingAsync(ct);
}
}Never inject scoped services (like DbContext) directly into a BackgroundService — background services are singletons. Always resolve them through IServiceScopeFactory.
PeriodicTimer (.NET 6+)
Task.Delay in a loop drifts over time because processing time isn't accounted for. PeriodicTimer fires on a real interval:
public class MetricsAggregatorService(ILogger<MetricsAggregatorService> logger)
: BackgroundService
{
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
using var timer = new PeriodicTimer(TimeSpan.FromMinutes(1));
while (await timer.WaitForNextTickAsync(stoppingToken))
{
try
{
await AggregateMetricsAsync(stoppingToken);
}
catch (Exception ex)
{
logger.LogError(ex, "Metrics aggregation failed");
// Timer keeps ticking regardless of failure
}
}
}
private Task AggregateMetricsAsync(CancellationToken ct)
{
// ... aggregate and flush metrics
return Task.CompletedTask;
}
}WaitForNextTickAsync returns false when cancellation is requested — the while loop exits cleanly without throwing.
Scoped Services in Background Workers
// The pattern: always use IServiceScopeFactory
public class EmailDispatchService(
IServiceScopeFactory scopeFactory,
ILogger<EmailDispatchService> logger) : BackgroundService
{
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
using var timer = new PeriodicTimer(TimeSpan.FromSeconds(30));
while (await timer.WaitForNextTickAsync(stoppingToken))
{
// Each iteration gets its own scope — DbContext is disposed after
await using var scope = scopeFactory.CreateAsyncScope();
var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
var emailSender = scope.ServiceProvider.GetRequiredService<IEmailSender>();
var pending = await db.OutboxMessages
.Where(m => m.SentAt == null)
.Take(50)
.ToListAsync(stoppingToken);
foreach (var message in pending)
{
await emailSender.SendAsync(message, stoppingToken);
message.SentAt = DateTime.UtcNow;
}
await db.SaveChangesAsync(stoppingToken);
}
}
}Registration
// Program.cs
builder.Services.AddHostedService<OutboxProcessorService>();
builder.Services.AddHostedService<MetricsAggregatorService>();
builder.Services.AddHostedService<EmailDispatchService>();Multiple hosted services run concurrently — StartAsync is called on each in registration order, but they all run in parallel.
Graceful Shutdown
// appsettings.json — give workers time to finish current work
{
"ShutdownTimeout": "00:00:30"
}
// Program.cs
builder.Services.Configure<HostOptions>(options =>
{
options.ShutdownTimeout = TimeSpan.FromSeconds(30);
});Always pass CancellationToken to every async call inside your worker. When Kubernetes sends SIGTERM, the token is cancelled — your worker has ShutdownTimeout seconds to finish current work before the process is killed.
Running as Windows Service
dotnet add package Microsoft.Extensions.Hosting.WindowsServices// Program.cs
builder.Host.UseWindowsService(options =>
{
options.ServiceName = "OrderProcessor";
});sc create OrderProcessor binPath="C:\services\OrderProcessor.exe"
sc start OrderProcessorRunning as Linux systemd Unit
dotnet add package Microsoft.Extensions.Hosting.Systemdbuilder.Host.UseSystemd();# /etc/systemd/system/order-processor.service
[Unit]
Description=Order Processor Worker
After=network.target
[Service]
WorkingDirectory=/opt/order-processor
ExecStart=/opt/order-processor/OrderProcessor
Restart=always
RestartSec=5
User=appuser
Environment=ASPNETCORE_ENVIRONMENT=Production
[Install]
WantedBy=multi-user.targetsystemctl enable order-processor
systemctl start order-processor
journalctl -u order-processor -f # follow logsWorker vs API — Deployment Topology
Standalone worker: Use when the job is compute-heavy or queue-driven and you want to scale it independently of the API. Deploy as its own container/service unit.
Worker alongside API: Register AddHostedService in an API project when the background work is tightly coupled (outbox pattern, cache refresh). One less deployment unit, but you can't scale them independently.
Summary
BackgroundService.ExecuteAsync+PeriodicTimeris the standard loop pattern in .NET 6+- Always use
IServiceScopeFactory— never injectDbContextor other scoped services directly - Pass
CancellationTokeneverywhere and configureShutdownTimeoutfor graceful stops UseWindowsService()/UseSystemd()require one package each and a single method call