Program.cs and the Hosting Model in ASP.NET Core: WebApplication and Environments
The two phases of the minimal hosting model: preparing services, configuration and logging on the builder, then the pipeline and endpoints on the app. Environments, app.Run() and the Generic Host behind it all.
Version note: The code in this post is written for .NET 6 and runs unchanged on .NET 10.
Guess First
The Program.cs below compiles, and there are no warnings. The only difference from the template is where one line sits: the line that registers controllers has moved below builder.Build(). What happens when you run the application? Do the controllers still work, do you see a warning, or does the application not start at all?
var builder = WebApplication.CreateBuilder(args);
var app = builder.Build();
builder.Services.AddControllers();
app.MapControllers();
app.Run();
Two Phases: Preparation and Execution
The answer: the application does not start. The moment you run it, it shuts down with this exception:
Unhandled exception. System.InvalidOperationException: The service collection cannot be modified because it is read-only.
at Microsoft.Extensions.DependencyInjection.ServiceCollection.ThrowReadOnlyException()
The reason is that the minimal hosting model has two separate phases. WebApplication.CreateBuilder(args) gives you a WebApplicationBuilder; this is the preparation phase. You register services and set up configuration and logging. When builder.Build() is called, that preparation is sealed: the dependency injection container is built and the service list becomes read-only. What you hold now is a WebApplication, and with it you only define the pipeline and the endpoints.
Think of a theater stage. Before the curtain rises, the set is built, the cast is chosen and the lights are adjusted. Once the curtain is up, you cannot bring a new actor onto the stage; you only direct how the play flows. Build() is the moment the curtain rises.
What Is Inside the builder
This is the Program.cs we will use throughout this post:
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddControllers();
builder.Logging.ClearProviders();
builder.Logging.AddConsole();
builder.Host.ConfigureHostOptions(options => options.ShutdownTimeout = TimeSpan.FromSeconds(15));
string catalogName = builder.Configuration["Catalog:Name"] ?? "ProductCatalog";
var app = builder.Build();
if (app.Environment.IsDevelopment())
{
app.Logger.LogInformation("Development mode: detailed error pages are on");
}
app.MapControllers();
app.MapGet("/info", (IWebHostEnvironment env) => new
{
Catalog = catalogName,
env.ApplicationName,
env.EnvironmentName,
PageSize = app.Configuration.GetValue<int>("Catalog:PageSize")
});
app.Lifetime.ApplicationStarted.Register(() => app.Logger.LogInformation("{Catalog} is ready", catalogName));
app.Run();
You will use four properties on builder a lot. builder.Services is the list of services you register with the dependency injection container; AddControllers() adds everything MVC needs for controllers. builder.Configuration is the combined view of settings read from appsettings files, environment variables and the command line. With builder.Logging you decide where logs go; in the example we cleared the default providers and kept only the console. builder.Environment tells you which environment the application runs in.
There are two more doors next to these: builder.Host and builder.WebHost. The first opens onto Generic Host settings, the second onto web server settings such as Kestrel. The ConfigureHostOptions line says that, when the application shuts down, it will wait 15 seconds for running work to finish. You rarely open these two doors, but you will run into them in older projects and in the setup docs of some libraries.
Dependency injection, configuration and logging each get a post of their own. Here our goal is to see where they sit in Program.cs.
A First Look at Configuration
The Catalog section in the example comes from two files:
{
"Logging": {
"LogLevel": {
"Default": "Information",
"Microsoft.AspNetCore": "Warning"
}
},
"AllowedHosts": "*",
"Catalog": {
"Name": "ProductCatalog",
"PageSize": 20
}
}
{
"Catalog": {
"PageSize": 5
}
}
The first is appsettings.json, the second appsettings.Development.json. You reach nested keys with a colon: "Catalog:Name". CreateBuilder loads configuration sources in a fixed order, and a later one overrides an earlier one: first appsettings.json, then the environment-specific file, then environment variables, and finally command-line arguments. In Development, user secrets also come in right after the environment-specific file. I ran the same application in different ways and looked at the /info endpoint:
$ ASPNETCORE_ENVIRONMENT=Development dotnet run --no-launch-profile --urls http://localhost:5214
$ curl http://localhost:5214/info
{"catalog":"ProductCatalog","applicationName":"ProductCatalog.Api","environmentName":"Development","pageSize":5}
$ ASPNETCORE_ENVIRONMENT=Production dotnet run --no-launch-profile --urls http://localhost:5214
$ curl http://localhost:5214/info
{"catalog":"ProductCatalog","applicationName":"ProductCatalog.Api","environmentName":"Production","pageSize":20}
$ ASPNETCORE_ENVIRONMENT=Staging Catalog__Name=StagingCatalog dotnet run --no-launch-profile --urls http://localhost:5214
$ curl http://localhost:5214/info
{"catalog":"StagingCatalog","applicationName":"ProductCatalog.Api","environmentName":"Staging","pageSize":20}
$ dotnet run --no-launch-profile --urls http://localhost:5214 --Catalog:PageSize=50
$ curl http://localhost:5214/info
{"catalog":"ProductCatalog","applicationName":"ProductCatalog.Api","environmentName":"Production","pageSize":50}
In Development pageSize is 5, because the environment-specific file overrode the main one. In Production there is no such file, so it stayed at 20. In environment variables you use a double underscore instead of a colon: Catalog__Name. The command line had the last word. In the last run I did not specify an environment, so the application started in Production. We will look at all of these layers and the options pattern in detail in the configuration post.
Environments
ASP.NET Core reads the environment name from the ASPNETCORE_ENVIRONMENT environment variable. If nothing is set, the environment is Production. That is a deliberate choice: something looking missing on a development machine is far cheaper than detailed error pages showing up on a live server because of a forgotten setting.
In code you check the environment through app.Environment. IsDevelopment(), IsStaging() and IsProduction() are built in, and for your own environment name you use IsEnvironment("Testing"). The environment name also decides which appsettings.{Environment}.json file is loaded; you saw that in the output above.
The launchSettings.json we saw in the previous post set this variable to Development during dotnet run. On the server, you provide the variable yourself: with ENV in Docker, as an app setting in a hosting panel, or in a systemd service definition.
app: Pipeline and Endpoints
The WebApplication returned by builder.Build() is the running form of the application. It has familiar properties too: app.Environment, app.Configuration, app.Services and app.Logger. The category of the log we wrote with app.Logger in the example is the application's name:
info: ProductCatalog.Api[0]
Development mode: detailed error pages are on
info: Microsoft.Hosting.Lifetime[14]
Now listening on: http://localhost:5214
info: Microsoft.Hosting.Lifetime[0]
Application started. Press Ctrl+C to shut down.
info: Microsoft.Hosting.Lifetime[0]
Hosting environment: Development
The real job of app is to describe the path requests will take. Calls that start with Use..., such as app.UseHttpsRedirection(), add middleware to the pipeline. Map... calls such as app.MapControllers() and app.MapGet(...) define endpoints. The /info endpoint is a minimal API; we will see its details in its own post, for now it is enough that it says "this function answers GET requests to this address".
There is also a small convenience that came with .NET 6: in the Development environment, WebApplication adds the developer exception page by itself. That is why you do not see an app.UseDeveloperExceptionPage() line in the template.
What app.Run() Does
app.Run() starts Kestrel, begins listening on the addresses from configuration and does not return until the application is shut down. The lines below it only run while the application is shutting down. I tried this by putting a Console.WriteLine("After Run"); below an app.Run("http://localhost:5050"); line: the text appeared only after I sent the application a shutdown signal and the "Application is shutting down..." log was written.
If you want to hook into the application's lifetime, there is app.Lifetime. ApplicationStarted.Register in the example runs after the server starts listening; in the logs you see the "ProductCatalog is ready" line after the "Application started" message. ApplicationStopping and ApplicationStopped are there for the shutdown moments.
Generic Host Behind the Curtain
WebApplication is not a separate world written from scratch. Underneath runs .NET's Generic Host: the same host that manages dependency injection, configuration, logging and the lifetime in worker services and console applications. On top of that, WebApplication gathers three roles into a single object:
IHost host = app;
IApplicationBuilder pipeline = app;
IEndpointRouteBuilder endpoints = app;
All three assignments compile, because WebApplication implements all three interfaces. As a host it is started and stopped, as a pipeline it takes middleware, and as an endpoint builder it holds route definitions. Before .NET 6 these roles were split between Program.cs and Startup.cs; we will see that setup, built with Host.CreateDefaultBuilder and the ConfigureServices and Configure methods, in the post about reading older projects.
Common Pitfalls
Registering services after Build(). As you saw at the start of the post, the application blows up on startup. Every builder.Services line must sit above Build().
Trying to change the environment through builder.WebHost. The line builder.WebHost.UseEnvironment("Staging") compiles too, but at runtime it throws: System.NotSupportedException: The environment changed from "Development" to "Staging". Changing the host configuration using WebApplicationBuilder.WebHost is not supported. Use WebApplication.CreateBuilder(WebApplicationOptions) instead. The message also tells you the fix:
var builder = WebApplication.CreateBuilder(new WebApplicationOptions
{
Args = args,
EnvironmentName = Environments.Staging
});
Most of the time it is better to provide the environment through an environment variable than to hard-code it; you use this pattern in test projects and special tools.
Writing code after app.Run() and expecting it to run. Those lines do not get their turn until the application shuts down.
Forgetting that Production is the default. If you forget to set the environment variable on the server, the application starts with Production settings; if you expect a Development-only setting, you will not find it.
A Small Experiment
★☆☆ Put the sample Program.cs into your own project, write a different Catalog:Name into appsettings.Development.json and watch the /info output change.
★★☆ Run the application with ASPNETCORE_ENVIRONMENT=Testing, create an appsettings.Testing.json file, and write a log line if app.Environment.IsEnvironment("Testing") is true.
★★★ Add a log to app.Lifetime.ApplicationStopping, stop the application with Ctrl+C and compare the log order with the "Application is shutting down..." message.
Behind the Use... lines we define on app there is a pipe that every request passes through in order. In the next post we open up the middleware pipeline; we look at Use, Run and Map, at why ordering matters so much, and at writing our own middleware.