The .NET Generic Host is the startup and lifetime model behind ASP.NET Core, worker services and most modern .NET applications. It wires together dependency injection, configuration, options and logging so every app starts the same way. This guide explains how the host assembles configuration and in what order sources override each other, how to bind and validate strongly typed options, and how to write structured, high-performance logs, including the version-specific changes in .NET 9 and .NET 10 that affect existing code.

What Is the .NET Generic Host?#

A host is an object that owns an application's resources and lifetime: the DI container, configuration, logging, and a set of hosted services that start and stop with the process. The Generic Host (Microsoft.Extensions.Hosting) is the non-web version of this idea, and ASP.NET Core's WebApplicationBuilder is built on the same foundation. Learning it once pays off everywhere: a background worker, a console tool, an ASP.NET Core API and an Azure Functions isolated worker all configure services, settings and logs with the same APIs.

The host exists to standardize cross-cutting concerns that every production application needs: reading settings from files and the environment, keeping secrets out of source control, logging consistently, and shutting down gracefully when the orchestrator sends a termination signal.

How the Generic Host Works#

Since .NET 7 the recommended entry point is Host.CreateApplicationBuilder, which returns a HostApplicationBuilder exposing properties such as Services, Configuration, Logging and Environment. You add registrations and sources in a straight line, call Build(), then run the host. The older callback style, Host.CreateDefaultBuilder, still works but is harder to read.

The builder applies a well-defined set of defaults:

  • Content root set to the current directory.
  • Host configuration from environment variables prefixed with DOTNET_ and from command-line arguments. This is where the environment name comes from.
  • App configuration from appsettings.json, then appsettings.{Environment}.json, then user secrets (Development only), then environment variables, then command-line arguments. .NET 10 also loads optional {ApplicationName}.settings.json and {ApplicationName}.settings.{Environment}.json files right after the appsettings files.
  • Logging to the Console, Debug and EventSource providers, plus EventLog on Windows, filtered by the Logging configuration section.
  • DI validation (ValidateScopes and ValidateOnBuild) enabled in the Development environment.

When the host runs, it starts every registered IHostedService, then waits for a shutdown signal such as Ctrl+C or SIGTERM. IHostApplicationLifetime exposes ApplicationStarted, ApplicationStopping and ApplicationStopped tokens plus StopApplication(), and HostOptions.ShutdownTimeout (30 seconds by default) bounds how long graceful shutdown may take. The background services guide covers the hosted service lifecycle in depth.

Getting Started with Host.CreateApplicationBuilder#

A worker created with dotnet new worker already contains this shape. The example below adds strongly typed, validated options and a hosted service that uses them:

C#
using Microsoft.Extensions.Options;

var builder = Host.CreateApplicationBuilder(args);

builder.Services.AddOptions<SmtpOptions>()
    .BindConfiguration(SmtpOptions.SectionName)
    .ValidateDataAnnotations()
    .ValidateOnStart();

builder.Services.AddHostedService<DigestSender>();

using var host = builder.Build();
await host.RunAsync();

public sealed class DigestSender(IOptionsMonitor<SmtpOptions> smtp, ILogger<DigestSender> logger)
    : BackgroundService
{
    protected override async Task ExecuteAsync(CancellationToken stoppingToken)
    {
        using var timer = new PeriodicTimer(TimeSpan.FromMinutes(15));
        do
        {
            logger.LogInformation("Sending digest through {SmtpHost}", smtp.CurrentValue.Host);
        }
        while (await timer.WaitForNextTickAsync(stoppingToken));
    }
}

The worker SDK's implicit usings bring in the hosting, DI, configuration and logging namespaces. Because ValidateDataAnnotations() lives in the Microsoft.Extensions.Options.DataAnnotations package, which the hosting package does not reference, a worker project must add it explicitly; ASP.NET Core projects already have it.

Configuration Providers and Precedence#

Configuration is a flat dictionary of string keys and values assembled from an ordered list of providers. Keys are hierarchical, with : as the separator, so the JSON path Smtp then Host becomes the key Smtp:Host. The central rule is simple: the provider added last wins for any key it supplies.

OrderSource (default host)Typical use
1appsettings.jsonDefaults committed to source control
2appsettings.{Environment}.jsonPer-environment overrides without secrets
3User secrets (Development only)Local developer credentials
4Environment variablesContainer, Kubernetes and App Service settings
5Command-line argumentsOne-off overrides, highest priority
6Anything you add afterward (for example Key Vault)Overrides all of the above

A typical appsettings.json holds non-secret defaults and logging levels:

JSON
{
  "Logging": {
    "LogLevel": {
      "Default": "Information",
      "Microsoft": "Warning",
      "Microsoft.Hosting.Lifetime": "Information"
    }
  },
  "Smtp": {
    "Host": "smtp.contoso.com",
    "Port": 587,
    "From": "noreply@contoso.com"
  },
  "KeyVaultName": "contoso-prod-kv"
}

Environment variables cannot contain : on every platform, so the provider maps a double underscore (__) to the separator. The command line accepts --Key=value, --Key value, /Key value and Key=value forms:

Bash
# Environment variables: __ becomes the ':' separator
export Smtp__Host=smtp.internal.contoso.com
export DOTNET_ENVIRONMENT=Staging

# Arguments after "--" go to the app; the command line overrides everything above it
dotnet run -- --Smtp:Port=2525 --environment Staging

JSON files are reloaded when they change, because the host sets reloadOnChange from the hostBuilder:reloadConfigOnChange setting, which defaults to true. File watching can be unreliable on network shares and some container volumes; setting DOTNET_USE_POLLING_FILE_WATCHER to true switches to polling. For Azure App Service, the environment variable provider also understands connection-string prefixes such as SQLCONNSTR_ and CUSTOMCONNSTR_, and .NET 10 extends that list with prefixes such as POSTGRESQLCONNSTR_, REDISCACHECONNSTR_ and SERVICEBUSCONNSTR_.

Environments: Development, Staging and Production#

The environment name selects environment-specific files and behaviors. The Generic Host reads it from DOTNET_ENVIRONMENT (or the --environment argument); ASP.NET Core also reads ASPNETCORE_ENVIRONMENT, and when both are set, WebApplication gives DOTNET_ENVIRONMENT precedence, so set only one to avoid confusion. If nothing is set, the environment is Production, which is the safe default: developer-only features stay off unless you opt in.

Use builder.Environment.IsDevelopment(), IsStaging(), IsProduction() or IsEnvironment("Name") to branch. Keep those branches rare and limited to infrastructure concerns, such as adding a secrets provider or enabling developer diagnostics. Business behavior that varies by environment belongs in configuration values, so the same build can be promoted unchanged from staging to production. Remember that environment variable names are case-sensitive on Linux, which matters when a setting works locally on Windows but not in a container.

Secrets: User Secrets and Azure Key Vault#

Secrets never belong in appsettings.json. During development, the Secret Manager stores them in a JSON file in your user profile, outside the repository, keyed by the UserSecretsId in the project file. It is a convenience, not a vault: the file is not encrypted.

Bash
dotnet user-secrets init
dotnet user-secrets set "Smtp:Password" "local-dev-password"
dotnet user-secrets list

In Azure, load secrets from Key Vault with the Azure.Extensions.AspNetCore.Configuration.Secrets and Azure.Identity packages. Key Vault secret names cannot contain colons, so the provider maps -- in a secret name to the : separator:

C#
using Azure.Identity;

var builder = Host.CreateApplicationBuilder(args);

if (!builder.Environment.IsDevelopment())
{
    // The vault secret "Smtp--Password" becomes the configuration key "Smtp:Password".
    builder.Configuration.AddAzureKeyVault(
        new Uri($"https://{builder.Configuration["KeyVaultName"]}.vault.azure.net/"),
        new DefaultAzureCredential());
}

Two details matter in production. First, DefaultAzureCredential is convenient but tries several credential types in turn; Microsoft's guidance is to use a specific credential, such as ManagedIdentityCredential, once deployed. Second, because Key Vault is added after the defaults, it overrides environment variables and the command line; call builder.Configuration.AddCommandLine(args) again afterward if operators must still be able to override values at launch. Secret rotation and managed identities are covered in the secrets management guide.

Strongly Typed Options with IOptions, IOptionsSnapshot and IOptionsMonitor#

Reading IConfiguration["Smtp:Port"] throughout the codebase spreads string keys and parsing everywhere. The options pattern binds a configuration section to a class once and injects it where needed. Options classes need a public parameterless constructor and public read-write properties; fields are not bound.

C#
using System.ComponentModel.DataAnnotations;

public sealed class SmtpOptions
{
    public const string SectionName = "Smtp";

    [Required]
    public string Host { get; set; } = "";

    [Range(1, 65535)]
    public int Port { get; set; } = 587;

    [Required, MinLength(3)]
    public string From { get; set; } = "";

    public string? Password { get; set; }
}

Consumers choose one of three interfaces, and the choice follows DI lifetimes. IOptions<T> is a singleton computed once, so it never sees reloaded values. IOptionsSnapshot<T> is scoped and recomputed once per scope, which suits request-level code in ASP.NET Core. IOptionsMonitor<T> is a singleton that always returns the current value and raises change notifications, which makes it the right choice inside singletons and hosted services:

C#
public sealed class SmtpConnectionPool : IDisposable
{
    private readonly IDisposable? _subscription;
    private SmtpOptions _current;

    public SmtpConnectionPool(
        IOptionsMonitor<SmtpOptions> monitor, ILogger<SmtpConnectionPool> logger)
    {
        _current = monitor.CurrentValue;
        _subscription = monitor.OnChange(updated =>
        {
            logger.LogInformation("SMTP settings changed; new host {Host}", updated.Host);
            Volatile.Write(ref _current, updated);
        });
    }

    public void Dispose() => _subscription?.Dispose();
}

Named options handle several instances of the same type, for example two SMTP relays: register them with Configure<SmtpOptions>("Bulk", section) and read them with Get("Bulk") on the snapshot or monitor. Post-configuration (PostConfigure) runs after all regular configuration and is useful for computed defaults. How these interfaces interact with lifetimes and captive dependencies is explained in the dependency injection guide.

Validating Options at Startup#

Invalid configuration should stop a deployment, not surface as a timeout at 3 a.m. By default, validation runs lazily the first time an options value is created, and failures throw OptionsValidationException. ValidateOnStart() moves that check to host startup, so a misconfigured release fails health checks immediately. The shortcut AddOptionsWithValidateOnStart<T>() registers options with startup validation in one call.

ValidateDataAnnotations() uses reflection over attributes such as [Required] and [Range]. For trimmed or Native AOT applications, and for faster startup in general, the options validation source generator (available since .NET 8) produces the same checks at compile time. You mark an empty partial class with [OptionsValidator] and register it as an IValidateOptions<T>:

C#
[OptionsValidator]
public sealed partial class SmtpOptionsValidator : IValidateOptions<SmtpOptions>
{
}

// In Program.cs: replaces ValidateDataAnnotations() with generated, reflection-free code
builder.Services.AddSingleton<IValidateOptions<SmtpOptions>, SmtpOptionsValidator>();
builder.Services.AddOptionsWithValidateOnStart<SmtpOptions>()
    .BindConfiguration(SmtpOptions.SectionName)
    .Validate(o => o.Host == "localhost" || !string.IsNullOrEmpty(o.Password),
        "Smtp:Password is required for remote SMTP hosts.");

The generator supports common attributes including [Required], [Range], [RegularExpression], [MinLength], [MaxLength] and [Length], and nested objects and collections can be validated with [ValidateObjectMembers] and [ValidateEnumeratedItems]. Cross-property rules that attributes cannot express belong in Validate(...) delegates or a hand-written IValidateOptions<T>. For AOT-friendly binding as well as validation, enable the configuration binding source generator with the EnableConfigurationBindingGenerator MSBuild property, as described in the Native AOT guide.

Logging with ILogger: Structured Logging Done Right#

ILogger<T> is the logging abstraction for all .NET code; the category defaults to the full type name of T, which is what filters match against. Log levels range from Trace (0) through Debug, Information, Warning and Error to Critical (5), and the default minimum is Information.

The single most important habit is to log with message templates, not string interpolation. In logger.LogInformation("Order {OrderId} shipped to {Country}", id, country), the provider receives the template and the named values separately, so a log backend can index OrderId and Country as fields. Interpolated strings flatten everything into text, allocate even when the level is disabled and defeat querying; analyzer CA2254 flags templates that vary between calls. Note that placeholders bind to arguments by position, not by name, so keep them in order.

Logging methods are synchronous by design and should be fast. If a destination is slow, put an in-memory queue in front of it and drain the queue from a background worker, rather than making request threads wait for log writes.

High-Performance Logging with the LoggerMessage Source Generator#

On hot paths, the [LoggerMessage] source generator is the recommended way to log. It generates a strongly typed method that checks IsEnabled before doing any work and avoids repeated template parsing and boxing of value-type arguments. It supports any number of parameters, unlike the older LoggerMessage.Define, and gives every message a stable event ID:

C#
internal static partial class Log
{
    [LoggerMessage(EventId = 2001, Level = LogLevel.Warning,
        Message = "Payment {PaymentId} declined by {Gateway}: {Reason}")]
    public static partial void PaymentDeclined(
        this ILogger logger, Guid paymentId, string gateway, string reason);
}

// Since the .NET 9 generator, an ILogger primary constructor parameter works too
public sealed partial class InvoiceMailer(ILogger<InvoiceMailer> logger)
{
    public void Send(Guid invoiceId, string recipient)
    {
        LogSending(invoiceId, recipient);
    }

    [LoggerMessage(Level = LogLevel.Information,
        Message = "Sending invoice {InvoiceId} to {Recipient}")]
    private partial void LogSending(Guid invoiceId, string recipient);
}

Logging methods must be partial and return void. The first Exception parameter is attached to the log entry rather than formatted into the message, and omitting Level from the attribute lets you pass the level as a parameter at runtime. Centralizing messages in Log classes also creates a catalog of what the service can emit, which makes dashboards and alerts easier to maintain.

Log Scopes, Filters and Console Formatters#

Scopes attach contextual values, such as an order or tenant ID, to every log written inside a block, without threading those values through every call. Filters decide which categories and levels reach each provider. Both can be set in code or in the Logging configuration section:

C#
builder.Logging.AddFilter("Microsoft.EntityFrameworkCore.Database.Command", LogLevel.Warning);
builder.Logging.AddJsonConsole(options =>
{
    options.IncludeScopes = true;
    options.UseUtcTimestamp = true;
});

// Inside a service
using (logger.BeginScope(new Dictionary<string, object?>
{
    ["OrderId"] = order.Id,
    ["TenantId"] = order.TenantId
}))
{
    logger.LogInformation("Reserving stock for {LineCount} lines", order.Lines.Count);
}

Filter rules resolve per provider: rules for the specific provider beat general ones, the longest matching category prefix wins, and among equal matches the last rule wins. Provider sections such as Logging:Console:LogLevel override the global Logging:LogLevel. The console provider has Simple, Json and Systemd formatters; JSON output suits containers whose logs are collected by an agent. For production telemetry, export logs through OpenTelemetry alongside traces and metrics, as shown in the OpenTelemetry guide. When volume becomes a cost problem, the Microsoft.Extensions.Telemetry package adds log sampling, for example AddRandomProbabilisticSampler().

What Changed in .NET 9, .NET 10 and .NET 11#

Several recent changes affect existing hosting, configuration and logging code:

  • .NET 9: the [LoggerMessage] generator can use an ILogger primary constructor parameter, removing the need for a logger field.
  • .NET 10, configuration nulls: the binder now binds JSON null values as null instead of skipping them, and the JSON provider no longer converts null to an empty string. Code that relied on a default surviving a null in configuration may behave differently.
  • .NET 10, JSON console logs: the formatted message now appears only at the top level of each entry, not again inside State. Log queries that read the message from State need updating.
  • .NET 10, host defaults: optional {ApplicationName}.settings.json files join the default configuration sources, and BackgroundService.ExecuteAsync now runs entirely on a background thread, so synchronous work at the start of ExecuteAsync no longer delays other services.
  • .NET 11 (upcoming): [ConfigurationIgnore] lets you exclude a property from configuration binding.

Best Practices#

  • Bind every section to an options class and inject options, not IConfiguration, into application services.
  • Validate options at startup with ValidateOnStart() and prefer the source-generated validator for AOT and trimming.
  • Keep secrets out of files: user secrets locally, Key Vault or platform secret stores in production, managed identities for access.
  • Use IOptionsMonitor<T> in singletons and IOptionsSnapshot<T> only in scoped code.
  • Log with templates and source-generated methods, and assign stable event IDs to important messages.
  • Set levels per category in configuration, keeping noisy framework categories at Warning in production.
  • Emit JSON logs in containers and ship them through OpenTelemetry or your platform's collector.
  • Keep environment checks in startup code only, so the same build runs everywhere.

Common Pitfalls#

  • Expecting IOptions<T> to reload. It never changes after first access; use the monitor.
  • Injecting IOptionsSnapshot<T> into a singleton, which is a captive dependency caught only by scope validation.
  • Misspelled section names. Binding a missing section silently yields defaults; startup validation with [Required] members turns that into an error.
  • Using : in environment variable names on Linux instead of __.
  • Adding a provider in the wrong place, so Key Vault silently overrides command-line values or vice versa.
  • String interpolation in log calls, which loses structure and allocates even when the level is disabled.
  • Logging secrets or personal data. Log identifiers, not payloads, and review what scopes and exceptions capture.

IOptions vs IOptionsSnapshot vs IOptionsMonitor#

FeatureIOptions<T>IOptionsSnapshot<T>IOptionsMonitor<T>
DI lifetimeSingletonScopedSingleton
Sees configuration reloadsNoYes, once per scopeYes, immediately
Named optionsNoYes, Get(name)Yes, Get(name)
Change notificationsNoNoYes, OnChange
Safe to inject into singletonsYesNoYes
Best forStatic settings read at startupPer-request settings in web appsHosted services, caches, long-lived clients

Frequently Asked Questions#

What is the difference between Host.CreateApplicationBuilder and Host.CreateDefaultBuilder?#

Both create a host with the same defaults for configuration, logging and DI. CreateApplicationBuilder, introduced in .NET 7, exposes Services, Configuration and Logging as properties you modify directly, while CreateDefaultBuilder uses chained callbacks. Prefer CreateApplicationBuilder for new code because it is linear and matches WebApplication.CreateBuilder.

Which configuration source wins when the same key is set twice?#

The provider added last wins. With the default host, command-line arguments override environment variables, which override user secrets, which override appsettings.{Environment}.json and then appsettings.json. Providers you add later, such as Key Vault, override all of them.

When should I use IOptionsMonitor instead of IOptionsSnapshot?#

Use IOptionsMonitor<T> in singletons and hosted services, or whenever you need change notifications. Use IOptionsSnapshot<T> in scoped code, such as controllers or request handlers, when you want values that stay consistent for the duration of a request.

How do I make an application fail fast on invalid configuration?#

Bind the section to an options class, add validation with data annotations, Validate delegates or the [OptionsValidator] source generator, and call ValidateOnStart() or use AddOptionsWithValidateOnStart<T>(). The host then throws an OptionsValidationException during startup instead of on first use.

Why use the LoggerMessage source generator instead of LogInformation?#

The generated methods check whether the level is enabled before doing any work, avoid boxing and repeated template parsing, and give messages stable event IDs. The difference matters on hot paths that log thousands of times per second; elsewhere, the main benefit is a consistent, discoverable catalog of log messages.

Summary#

  • Host.CreateApplicationBuilder gives every app the same defaults for DI, configuration, logging and graceful shutdown.
  • Configuration providers are layered and the last one wins; environment variables use __, and secrets come from user secrets locally and Key Vault in Azure.
  • Bind settings to options classes, pick the options interface that matches the consumer's lifetime and validate at startup.
  • Log with message templates, source-generated [LoggerMessage] methods, scopes and per-category filters.
  • Review the .NET 10 changes to null binding, JSON console output and BackgroundService startup when upgrading.

Further Reading#