.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.

C#
// 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

C#
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:

C#
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

C#
// 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

C#
// 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

C#
// 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

Bash
dotnet add package Microsoft.Extensions.Hosting.WindowsServices
C#
// Program.cs
builder.Host.UseWindowsService(options =>
{
    options.ServiceName = "OrderProcessor";
});
POWERSHELL
sc create OrderProcessor binPath="C:\services\OrderProcessor.exe"
sc start OrderProcessor

Running as Linux systemd Unit

Bash
dotnet add package Microsoft.Extensions.Hosting.Systemd
C#
builder.Host.UseSystemd();
INI
# /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.target
Bash
systemctl enable order-processor
systemctl start order-processor
journalctl -u order-processor -f   # follow logs

Worker 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 + PeriodicTimer is the standard loop pattern in .NET 6+
  • Always use IServiceScopeFactory — never inject DbContext or other scoped services directly
  • Pass CancellationToken everywhere and configure ShutdownTimeout for graceful stops
  • UseWindowsService() / UseSystemd() require one package each and a single method call