The Generic Host
By the end of this lesson
Explain what the host sets up before your code runs.
The host is the object that owns everything outside your business logic: the configuration, the service container, the logging factory, and the lifetime of any long-running work. It is called the generic host because it is not specific to web applications — a console tool, a message consumer and an API all use the same one, and a web application adds its server on top of it.
Three lessons in this course described pieces that appear from somewhere: layered configuration, a container with registrations, loggers with categories and levels. The host is that somewhere. It is a small amount of code that assembles them in a fixed order and then hands you the result.
The word for this shape is a composition root: one place where the application is assembled, running once at start-up, after which nothing else constructs anything. Everything in the rest of your codebase asks for what it needs and receives it.
You will meet an older spelling in existing code: Host.CreateDefaultBuilder with ConfigureServices and ConfigureAppConfiguration callbacks. It does the same work in a different shape. Newer code uses Host.CreateApplicationBuilder, which exposes Configuration, Services, Logging and Environment as properties rather than as callbacks.
What Host.CreateApplicationBuilder has done by the time it returns, before a line of your code runs:
Establishes the content root and the environment name
It reads host-level settings from DOTNET_ prefixed environment variables and from the command line. The environment name is settled first, because the configuration sources that come next depend on it.
Builds configuration, in order
appsettings.json, then appsettings.{Environment}.json, then user secrets when the environment is Development, then environment variables, then command-line arguments. The result is the merged view described in the configuration lesson.
Sets up logging
Reads the Logging section, creates the factory, and adds the default providers so that console output goes somewhere sensible before you configure anything. ILogger<T> is resolvable from the container from this point on.
Creates the service collection with the platform's own registrations
Configuration, the environment, the logger factory and the host lifetime are all registered so your classes can ask for them. Your registrations are added to the same collection.
On Build, creates the container and locks it
The service provider is built from the collection, and in Development it is created with scope validation enabled. After this point registration is closed: adding a service to a built host is not possible.
On Run, starts hosted services and waits
Every registered IHostedService is started in registration order, and the host then blocks until a stop is requested. That request is what the lifecycle lesson is about.
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
HostApplicationBuilder builder = Host.CreateApplicationBuilder(args);
builder.Services
.AddOptions<EmployeeOptions>()
.Bind(builder.Configuration.GetSection(EmployeeOptions.SectionName))
.ValidateDataAnnotations()
.ValidateOnStart();
builder.Services.AddSingleton<IClock, SystemClock>();
builder.Services.AddScoped<IEmployeeStore, PostgresEmployeeStore>();
builder.Services.AddScoped<EmployeeService>();
// Long-running work, started by the host and stopped by it
builder.Services.AddHostedService<EmployeeSyncWorker>();
using IHost host = builder.Build();
await host.RunAsync();- Fifteen lines, and the application now has layered configuration, validated options, a container, logging and a managed background worker. That is the return on using a host rather than a plain Main method.
- Everything before Build is registration. Everything after it is running. Keeping that boundary sharp is what makes start-up possible to reason about — there is exactly one place where the application is assembled.
- AddHostedService registers a class the host will start and stop. The host owns its lifetime; nothing in your code calls it.
- The using declaration on the host matters. Disposing it disposes the container, which disposes every singleton that implements IDisposable. Without it, cleanup on shutdown is left to chance.
- RunAsync starts the hosted services and then waits for a stop request. Control does not come back to the next line until the host is stopping.
The four pieces you use to put your own code into the host's lifetime:
- IHostedService
- Two methods: StartAsync and StopAsync. The host calls Start on each registered service in order during start-up, and Stop in reverse order during shutdown. Suitable for work that begins and ends — opening a connection, registering with a discovery service, warming a cache.
- BackgroundService
- An abstract class implementing IHostedService for the common case: one long-running loop. You override ExecuteAsync and receive a cancellation token that is signalled when the host is stopping. This is what you want for a worker, a queue consumer or a scheduled job.
- IHostApplicationLifetime
- Injectable, and gives you three notifications — started, stopping, stopped — plus StopApplication to request shutdown yourself. That last one is how a job-style application exits when its work is finished.
- IHostedLifecycleService
- An extension of IHostedService with hooks either side of start and stop, for work that has to happen before anything else starts or after everything else has stopped. Reach for it when ordering across several hosted services actually matters, and not before.
// Start and finish: runs once during start-up, cleans up during shutdown
public sealed class PayrollConnectionWarmup(
IPayrollClient client,
ILogger<PayrollConnectionWarmup> logger) : IHostedService
{
public async Task StartAsync(CancellationToken cancellationToken)
{
// The host is waiting for this to return, so keep it short
await client.OpenAsync(cancellationToken);
logger.LogInformation("Payroll connection ready");
}
public Task StopAsync(CancellationToken cancellationToken) =>
client.CloseAsync(cancellationToken);
}
// A loop: runs for the life of the process
public sealed class LeaveAccrualWorker(
IServiceScopeFactory scopeFactory,
ILogger<LeaveAccrualWorker> logger) : BackgroundService
{
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
using var timer = new PeriodicTimer(TimeSpan.FromHours(1));
while (await timer.WaitForNextTickAsync(stoppingToken))
{
using IServiceScope scope = scopeFactory.CreateScope();
var service = scope.ServiceProvider.GetRequiredService<EmployeeService>();
int accrued = await service.AccrueLeaveAsync(stoppingToken);
logger.LogInformation("Accrued leave for {EmployeeCount} employees", accrued);
}
}
}- StartAsync is awaited by the host before it moves on. The application is not running yet while you are inside it, so a slow call here delays start-up for everything — and a call that never returns means the application never starts, with no error to explain the hang.
- PeriodicTimer with WaitForNextTickAsync is a cleaner loop than a delay inside a while, because the token is handled by the wait. When the host stops, WaitForNextTickAsync throws a cancellation exception that the host treats as an ordinary stop rather than a failure.
- The worker creates a scope per cycle instead of taking EmployeeService in its constructor. A background service is effectively a singleton, so a scoped dependency in its constructor would be captive — the dependency injection lesson covers the damage that causes.
- The token is passed all the way down into AccrueLeaveAsync. A worker that accepts a token and never forwards it cannot be stopped promptly, which becomes the lifecycle lesson's problem.
- An unhandled exception escaping ExecuteAsync stops the whole host by default. That is deliberate — a worker that has silently died while the process stays healthy is worse than a crash — so decide explicitly which failures you catch and retry inside the loop.
Summary
- The host owns configuration, the container, logging and the lifetime of long-running work, and is not web-specific
- By the time the builder returns, the environment name is settled, configuration is layered, logging is ready and platform services are registered
- Build creates the container and closes registration; Run starts hosted services in registration order and waits
- IHostedService is for work that starts and finishes; BackgroundService is for a long-running loop with a cancellation token, and being effectively a singleton it must create a scope per unit of work
- StartAsync is awaited by the host, so slow or blocking work there delays or prevents start-up
Practice
Attempt each one before opening the solution. Getting it wrong first is how the idea sticks.
Try it yourself
Watch the start-up order for yourself
Create a worker project with two hosted services. Have each log a line in StartAsync and another in StopAsync, and register one background service that logs once per second.
Run it, let it tick a few times, then press Ctrl+C. Write down the order of every line.
Now put a five-second delay at the top of the first StartAsync and run it again. When does the second service start, and when does the background loop produce its first line?
Show solution
Start happens in registration order and stop happens in reverse. That reversal is deliberate: a service registered later may depend on an earlier one, so it has to be shut down before the thing it depends on disappears.
With the delay, nothing else starts for five seconds. The host awaits each StartAsync in sequence, so a slow one holds up every service behind it and the background loop as well. This is the mechanism behind an application that appears to hang at start-up with no error.
The fix in real code is to get out of StartAsync quickly. If something needs retrying until a dependency is available, do that inside a background service, where waiting does not block start-up.
Think about it
Why is configuration built before the container?
The host builds configuration, then sets up logging, then creates the container. Why can that order not be rearranged?
And what does that imply about code that tries to read configuration during registration?
Show solution
Registrations depend on configuration. Binding an options class needs the configuration section, and a branch on the environment name needs the environment name, so both have to be settled before registration can happen. Logging comes next so that the container and anything constructed after it can write logs.
Reading configuration during registration is therefore correct and expected — builder.Configuration is fully populated at that point. That is how the environment branch in the environments lesson works.
The thing you cannot do is resolve services during registration to help you decide what to register. The container does not exist yet. When a registration decision genuinely depends on a service, the tool is a factory registration that receives the provider and runs later, when the container is available.
Saved in this browser only.