While hosted services in ASP.NET Core run in the same process as the web API, some background workloads benefit from a separate process: isolation (a background crash does not take down the API), independent scaling (run 10 worker instances vs 2 API instances), and independent deployment (deploy a new worker version without touching the API). The .NET Worker Service template creates a lightweight console application built on the Generic Host — it supports DI, configuration, logging, and hosted services, but without ASP.NET Core’s HTTP stack.
Worker Service Structure
// dotnet new worker -n BlogApp.Worker
// ── Program.cs — worker is a Generic Host without HTTP ───────────────────
var builder = Host.CreateApplicationBuilder(args);
// Same DI, configuration, and logging patterns as ASP.NET Core
builder.Services.AddHostedService<ImageProcessingWorker>();
builder.Services.AddScoped<IImageResizer, ImageResizer>();
builder.Services.AddDbContext<AppDbContext>(opts =>
opts.UseSqlServer(builder.Configuration.GetConnectionString("Default")));
// Windows Service or Linux systemd support
builder.Services.AddWindowsService(opts => opts.ServiceName = "BlogApp Image Worker");
builder.Services.AddSystemd(); // for Linux systemd
var host = builder.Build();
await host.RunAsync();
// ── Worker.cs — the background service ───────────────────────────────────
public class ImageProcessingWorker(
IServiceScopeFactory scopeFactory,
ILogger<ImageProcessingWorker> logger) : BackgroundService
{
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
logger.LogInformation("Image processing worker starting.");
using var timer = new PeriodicTimer(TimeSpan.FromSeconds(5));
while (await timer.WaitForNextTickAsync(stoppingToken))
{
using var scope = scopeFactory.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
var resizer = scope.ServiceProvider.GetRequiredService<IImageResizer>();
var pending = await db.ImageJobs
.Where(j => j.Status == JobStatus.Pending)
.Take(10)
.ToListAsync(stoppingToken);
foreach (var job in pending)
{
await resizer.ResizeAsync(job, stoppingToken);
job.Status = JobStatus.Completed;
}
await db.SaveChangesAsync(stoppingToken);
}
}
}
Host.CreateApplicationBuilder(args) instead of WebApplication.CreateBuilder(args). It does not register Kestrel, routing, or any HTTP middleware — just the Generic Host with DI, configuration, and logging. All the patterns from this chapter (BackgroundService, IServiceScopeFactory, PeriodicTimer, Channel<T>) work identically in a Worker Service as in an ASP.NET Core application. The only difference is there is no HTTP pipeline, so the worker is entirely background processing.ImageJob, EmailJob), the repository interfaces, and domain entities. Both projects reference the shared library. This avoids code duplication and ensures the API and worker use identical database schemas and business logic. The shared library should be a class library targeting the same framework version as both applications.Microsoft.Extensions.Hosting.WindowsServices for Windows Service support and Microsoft.Extensions.Hosting.Systemd for Linux systemd. Without AddWindowsService() or AddSystemd(), the host does not integrate with the service manager and will not start/stop correctly as a system service. Also ensure the UseContentRoot(AppContext.BaseDirectory) is set so the application can find its configuration files when run as a service (the working directory is not the application directory for system services).Shared Queue Between API and Worker
// ── Database-backed queue: API writes, Worker reads ───────────────────────
// API project — controller enqueues work
[HttpPost("{id}/process-images")]
public async Task<IActionResult> ProcessImages(int id, CancellationToken ct)
{
var post = await _repo.GetByIdAsync(id, ct);
if (post is null) return NotFound();
// Write job to database table — Worker polls this table
var job = new ImageJob
{
PostId = post.Id,
ImagePath = post.CoverImagePath,
Status = JobStatus.Pending,
CreatedAt = DateTime.UtcNow,
};
_db.ImageJobs.Add(job);
await _db.SaveChangesAsync(ct);
return Accepted(new { jobId = job.Id });
}
// Worker project — polls and processes
// Worker reads from ImageJobs where Status = Pending
// Updates Status = Processing, processes, updates Status = Completed/Failed
// This pattern is the "outbox pattern" / "transactional outbox"
Common Mistakes
Mistake 1 — Not setting UseContentRoot in Worker Service deployed as a system service
❌ Wrong — working directory is not the application directory; appsettings.json not found.
✅ Correct — set builder.UseContentRoot(AppContext.BaseDirectory) for system service deployments.
Mistake 2 — Putting CPU-intensive background work in the same process as the Web API
❌ Wrong — heavy CPU work in a hosted service competes with request handling threads, increasing API latency.
✅ Correct — move CPU-intensive background work (image processing, report generation, ML inference) to a separate Worker Service process that can scale and be resource-limited independently.