Worker Service
A Worker Service is a .NET project type designed for long-running background processes — things that run continuously without a UI or HTTP layer. Common use cases: message queue consumers, scheduled jobs, device listeners.
Entry Point
Like any .NET app, everything starts in Program.cs. The Host is the .NET runtime container — it manages the app's lifetime, configuration, logging, and dependency injection.
var builder = Host.CreateApplicationBuilder(args);
builder.Services.AddWindowsService(options => options.ServiceName = "MQTTListener");
builder.Services.Configure<MqttSettings>(builder.Configuration.GetSection("Mqtt"));
builder.Services.AddHostedService<Worker>();
var host = builder.Build();
host.Run();
CreateApplicationBuilder automatically loads appsettings.json (and appsettings.{Environment}.json) into builder.Configuration. No manual wiring needed.
AddHostedService<Worker> registers Worker as the background process — the host will start it when the app starts and stop it on shutdown.
AddWindowsService is optional — it lets the app run as a Windows Service (installable via sc.exe). When run from the terminal directly it has no effect.
The Worker
The main logic lives in a class that extends BackgroundService. The framework calls ExecuteAsync once on startup and keeps it running until the app stops.
public class Worker(ILogger<Worker> logger, IOptions<MqttSettings> options) : BackgroundService
{
private readonly MqttSettings _settings = options.Value;
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
while (!stoppingToken.IsCancellationRequested)
{
// main logic here
await Task.Delay(5000, stoppingToken);
}
}
}
Cancellation
stoppingToken is a CancellationToken owned and controlled by the host — your code can only observe it, not cancel it. The host cancels it automatically when a stop signal is received: Ctrl+C in the terminal, the Windows Service manager stopping the service, or the OS shutting down.
Nothing in your code ever flips IsCancellationRequested to true — that's the host's job.
Passing the token to Task.Delay means the delay exits immediately when the host stops, instead of blocking shutdown for up to 5 seconds. It does this by throwing OperationCanceledException internally, which the host catches and treats as a normal stop.
Configuration
Config is bound from appsettings.json into a strongly-typed class:
public class MqttSettings
{
public string Host { get; set; } = "localhost";
public int Port { get; set; } = 1883;
public string Topic { get; set; } = "#";
public string ClientId { get; set; } = "MQTTListener";
}
The binding is registered in Program.cs:
This stores a recipe in the DI container: "when IOptions<MqttSettings> is requested, read the Mqtt JSON section and map it onto a MqttSettings instance." Nothing is deserialized yet at this point.
If a JSON key is missing, the property silently falls back to its default value — no exception. To catch bad config at startup instead of at first use, add
.ValidateDataAnnotations().ValidateOnStart()to the registration.The defaults defined in the POCO class are only used when the key is completely absent from
appsettings.json. If the key exists in the JSON, even with a different value, the JSON always wins.
Logging
CreateApplicationBuilder wires up a console logger by default. This works fine when running from the terminal or Visual Studio, but has no effect when running as a real Windows Service — there is no console window, so the output goes nowhere.
For a production service, use file logging instead. A simple approach is a generic class that takes a path and writes timestamped lines:
public sealed class FileLogger : IAsyncDisposable
{
private readonly StreamWriter _writer;
private readonly SemaphoreSlim _lock = new(1, 1);
public FileLogger(string path)
{
Directory.CreateDirectory(Path.GetDirectoryName(Path.GetFullPath(path))!);
_writer = new StreamWriter(path, append: true) { AutoFlush = true };
}
public async Task WriteAsync(string message)
{
await _lock.WaitAsync();
try
{
await _writer.WriteLineAsync($"[{DateTime.Now:yyyy-MM-dd HH:mm:ss}] {message}");
}
finally
{
_lock.Release();
}
}
public async ValueTask DisposeAsync()
{
await _writer.DisposeAsync();
_lock.Dispose();
}
}
SemaphoreSlim is used instead of lock because lock does not work with async — it can't be held across an await.
IAsyncDisposable ensures the file is flushed and closed cleanly when the host shuts down. The DI container calls DisposeAsync automatically on registered singletons at shutdown.
To have multiple independent log files (e.g. one for service events, one for data), register two instances of the same class using keyed services:
builder.Services.AddKeyedSingleton<FileLogger>("service", (sp, _) =>
new FileLogger(sp.GetRequiredService<IOptions<FileLoggingSettings>>().Value.ServiceLogPath));
builder.Services.AddKeyedSingleton<FileLogger>("data", (sp, _) =>
new FileLogger(sp.GetRequiredService<IOptions<FileLoggingSettings>>().Value.DataLogPath));
Then inject them by key in the constructor:
public class Worker(
[FromKeyedServices("service")] FileLogger serviceLog,
[FromKeyedServices("data")] FileLogger dataLog) : BackgroundService
The key ("service", "data") is what the container uses to tell the two instances apart — since they are the same type, a plain injection without a key would be ambiguous.
Dependency Injection
The Worker constructor uses C# 12 primary constructor syntax — dependencies are declared directly on the class line instead of in a separate constructor body. A classic constructor works exactly the same way, this is just a shorter form:
When the host creates Worker, the DI container resolves each parameter automatically:
| Parameter | Registered by |
|---|---|
ILogger<Worker> |
CreateApplicationBuilder (built-in) |
IOptions<MqttSettings> |
Configure<MqttSettings>(...) in Program.cs |
IOptions<T> is a wrapper — the actual deserialization runs when you call .Value:
The <MqttSettings> type parameter serves two purposes: it acts as the lookup key in the DI container (so IOptions<MqttSettings> matches the right registration), and it tells the binder what shape to produce when mapping the JSON.
See Solution Structure for how this project fits into a broader .NET solution layout.