What the Host Does

Explain what the .NET Generic Host owns (DI, configuration, logging, lifetime, hosted services) and pick the right builder for an API or a worker.

Syntaxvar builder = Host.CreateApplicationBuilder(args); // or WebApplication.CreateBuilder(args) builder.Services.AddHostedService<ReorderScanner>(); using IHost host = builder.Build(); await host.RunAsync();

Every .NET 10 back-end process, an HTTP API or a queue worker, runs inside a host: an object implementing IHost that owns the infrastructure every service needs. You configure it through a builder, call Build(), then start it. Knowing what the host owns explains most start-up and shutdown behaviour you will debug later.

What the host owns

ConcernHost componentWhere you configure it
Dependency injectionIServiceProvider built from an IServiceCollectionbuilder.Services
ConfigurationIConfiguration (a ConfigurationManager while building)builder.Configuration
LoggingILoggerFactory and its providersbuilder.Logging
EnvironmentIHostEnvironment: name, content root, application namebuilder.Environment
LifetimeIHostApplicationLifetime events, IHostLifetime (signals)HostOptions, app.Lifetime
Long-running workevery registered IHostedServiceAddHostedService<T>()

WebApplication is a host

WebApplication implements IHost, plus IApplicationBuilder for middleware and IEndpointRouteBuilder for endpoints. Kestrel is not special: it runs as a hosted service (GenericWebHostService) that is registered after yours. So your hosted services start before the port opens, and on shutdown the server stops accepting and drains requests before your services stop. For a process with no HTTP, use Host.CreateApplicationBuilder(args): the same builder shape without Kestrel or a pipeline.

Older code uses Host.CreateDefaultBuilder() with ConfigureServices and ConfigureAppConfiguration callbacks that run later, at Build(). It still works, but new code should use the linear CreateApplicationBuilder style, where configuration is readable the moment a source is added.

The defaults you get

  • Content root is the current working directory.
  • Configuration, later sources winning: DOTNET_-prefixed environment variables (host settings), appsettings.json, appsettings.{Environment}.json, user secrets in Development, environment variables, command-line arguments.
  • Logging providers: Console, Debug and EventSource (plus EventLog on Windows).
  • DI scope and build validation, only in Development.
  • ConsoleLifetime: Ctrl+C, SIGINT and SIGTERM trigger a graceful stop.

The lifecycle is: Build(), StartAsync (hosted services in registration order), ApplicationStarted, wait for a signal, ApplicationStopping, hosted services stopped in reverse order, ApplicationStopped, then the container disposes the singletons it created.

What goes wrong in production

  • Wrong working directory. All JSON files are optional, so a service started from / by a unit file without WorkingDirectory silently runs with no appsettings.json at all.
  • A second container. Calling builder.Services.BuildServiceProvider() during registration builds another provider with its own singletons; you get two caches and two connection pools.
  • Resolving scoped services from app.Services. That is the root scope: a scoped DbContext resolved there lives forever. Create a scope.

Example

Try it yourself
Loading editor…
Press Run to execute the code.

When to use it

  • Explain in an interview why a hosted service registered in Program.cs starts before Kestrel accepts its first request.
  • Build a queue consumer that shares the API's DI, configuration and logging setup without pulling in Kestrel, using Host.CreateApplicationBuilder.
  • Track down a service that runs with default settings in production because its content root is not the folder holding appsettings.json.

More examples

The same host behind a web API

<code>app.Services</code>, <code>app.Configuration</code>, <code>app.Environment</code>, <code>app.Logger</code> and <code>app.Lifetime</code> are the same host components a worker has. The refresher is a hosted service like Kestrel itself; because it was registered first, it starts before the server listens and stops after the server has drained. <code>app.Run()</code> blocks until a shutdown signal arrives.

Example · csharp
var builder = WebApplication.CreateBuilder(args);

builder.Services.AddSingleton<ExchangeRates>();
builder.Services.AddHostedService<ExchangeRateRefresher>();

var app = builder.Build();

// WebApplication exposes the host's parts directly.
app.Logger.LogInformation("Environment {Environment}, content root {ContentRoot}",
    app.Environment.EnvironmentName, app.Environment.ContentRootPath);

app.Lifetime.ApplicationStopping.Register(() => app.Logger.LogInformation("Draining requests"));

app.MapGet("/rates/{currency}", (string currency, ExchangeRates rates) =>
    rates.TryGet(currency, out var rate) ? Results.Ok(new { currency, rate }) : Results.NotFound());

app.Run();

sealed class ExchangeRates
{
    private volatile Dictionary<string, decimal> _rates = new() { ["EUR"] = 0.92m, ["GBP"] = 0.79m };
    public bool TryGet(string currency, out decimal rate) => _rates.TryGetValue(currency.ToUpperInvariant(), out rate);
    public void Replace(Dictionary<string, decimal> rates) => _rates = rates;
}

sealed class ExchangeRateRefresher(ExchangeRates rates, ILogger<ExchangeRateRefresher> logger) : BackgroundService
{
    protected override async Task ExecuteAsync(CancellationToken stoppingToken)
    {
        using var timer = new PeriodicTimer(TimeSpan.FromMinutes(15));
        while (await timer.WaitForNextTickAsync(stoppingToken))
        {
            rates.Replace(new() { ["EUR"] = 0.93m, ["GBP"] = 0.78m });
            logger.LogInformation("Exchange rates refreshed");
        }
    }
}

Older CreateDefaultBuilder style next to the modern one

Both produce the same <code>IHost</code>. The callback style defers everything to <code>Build()</code>, where callbacks run in phases (host configuration, then app configuration, then services), so configuration cannot be read outside a callback and the order is hard to follow. <code>Host.CreateApplicationBuilder</code> (and <code>WebApplication.CreateBuilder</code>) apply each call at once, which is easier to read; the trade-off is that a value read during registration only sees the sources added so far. Use it for new services and migrate old ones when you touch their start-up code.

Example · csharp
// Older style: callbacks are queued and run inside Build(), grouped by kind.
IHost legacy = Host.CreateDefaultBuilder(args)
    .ConfigureAppConfiguration(config => config.AddJsonFile("pricing.json", optional: true))
    .ConfigureServices((context, services) =>
    {
        var currency = context.Configuration["Pricing:Currency"] ?? "USD";
        services.AddSingleton(new PricingSettings(currency));
    })
    .Build();

// Modern style: each line takes effect immediately, so configuration is readable right away.
var builder = Host.CreateApplicationBuilder(args);
builder.Configuration.AddJsonFile("pricing.json", optional: true);
var currencyCode = builder.Configuration["Pricing:Currency"] ?? "USD";
builder.Services.AddSingleton(new PricingSettings(currencyCode));
using IHost modern = builder.Build();

legacy.Dispose();

record PricingSettings(string Currency);

Pitfall: building a second service provider

<code>BuildServiceProvider()</code> during registration creates a second container, so every singleton exists twice: two caches that disagree, two connection pools, and objects that are never disposed. The ASP0000 analyzer warns about it. A factory registration, the options pattern or <code>IConfigureOptions&lt;T&gt;</code> runs inside the real container instead.

Example · csharp
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddSingleton<CustomerCache>();

// Wrong: this builds a separate container with its own CustomerCache singleton.
// using var temporary = builder.Services.BuildServiceProvider();
// var cache = temporary.GetRequiredService<CustomerCache>();

// Right: defer the lookup to the real container with a factory registration.
builder.Services.AddSingleton<ICustomerLookup>(sp =>
    new CachedCustomerLookup(sp.GetRequiredService<CustomerCache>()));

var app = builder.Build();
app.MapGet("/customers/{id:int}", (int id, ICustomerLookup lookup) => lookup.Find(id));
app.Run();

sealed class CustomerCache
{
    public Dictionary<int, string> Names { get; } = new() { [42] = "Contoso Retail" };
}

interface ICustomerLookup { string? Find(int id); }

sealed class CachedCustomerLookup(CustomerCache cache) : ICustomerLookup
{
    public string? Find(int id) => cache.Names.GetValueOrDefault(id);
}

Discussion

  • Be the first to comment on this lesson.