Skip to content
ExponaBLOG
.NET & ASP.NET CORE · DAY 003 / 100

Program.cs, line by line: a hands-on guide for engineers

The file every .NET dev touches — and few can explain fully.

Expona Admin6 min read

Understanding Program.cs: The entry point that controls everything

Most .NET developers can run an ASP.NET Core application without thinking much about what happens in Program.cs. Ship it, deploy it, move on. But when middleware breaks mysteriously, when dependency injection fails in unexpected ways, or when you're trying to understand why a service isn't available in a particular scope, you'll find yourself staring at this one file wishing you understood it better. This guide walks through Program.cs, line by line: a hands-on guide for engineers who want to move from "it works" to "I know why it works."

What CreateBuilder actually does

WebApplicationBuilder.CreateBuilder() is where everything begins. When you call this method, .NET isn't just creating an empty container. It's wiring in sensible defaults that handle configuration, logging, and dependency injection setup before you write a single line of custom code.

csharp
var builder = WebApplication.CreateBuilder(args);

Here's what that one line does behind the scenes:

  • Configuration loading: Reads appsettings.json and appsettings.{Environment}.json in the correct order, then layers on environment variables and command-line arguments.
  • Logging configuration: Sets up a default logging provider (console in development, event log on Windows in production) with sensible filters already applied.
  • Dependency injection container: Creates an IServiceCollection (the composition root) where you'll register every service your application needs.
  • Hosted service infrastructure: Prepares the foundation for background services that run alongside your web server.

The args parameter matters. It allows command-line overrides to reach your configuration system. If you're running locally and want to test a production configuration, you can pass it without changing JSON files. In cloud deployments, this is how environment-specific settings get injected at runtime.

In real projects, we've seen teams spend hours troubleshooting configuration issues only to realize the JSON wasn't being read at all because the file wasn't in the output directory. CreateBuilder handles the plumbing correctly, but you still need to ensure your files are there.

builder.Services: Your composition root and why order matters

Once CreateBuilder finishes, you have a builder object with a Services property. This is the composition root—the place where you tell .NET about every service, repository, handler, or utility class your application needs.

csharp
builder.Services.AddScoped<IUserRepository, UserRepository>();
builder.Services.AddScoped<IUserService, UserService>();
builder.Services.AddSingleton<ICache, MemoryCache>();
builder.Services.AddTransient<IEmailNotifier, SmtpEmailNotifier>();

Three lifetimes matter here:

  • Transient: New instance every time. Use for stateless utilities or classes without dependencies on mutable state.
  • Scoped: One instance per HTTP request. Perfect for repositories, unit-of-work patterns, and database contexts. This is your default for most business logic.
  • Singleton: One instance for the lifetime of the application. Use only for truly stateless services like caches, configuration readers, or logging factories. Never put a scoped or transient dependency into a singleton, or you'll have a captive dependency—a memory leak waiting to happen.

The order you register services rarely matters for functionality, but it matters for clarity. We've seen codebases where related registrations are scattered across 500 lines, making it impossible to understand the dependency graph. Group them logically:

csharp
// Data access
builder.Services.AddScoped<IUserRepository, UserRepository>();
builder.Services.AddScoped<IOrderRepository, OrderRepository>();

// Business logic
builder.Services.AddScoped<IUserService, UserService>();
builder.Services.AddScoped<IOrderService, OrderService>();

// Infrastructure
builder.Services.AddSingleton<ICache, RedisCache>();
builder.Services.AddSingleton<IMetricsCollector, PrometheusCollector>();

This structure tells the next engineer (or future you) the architecture at a glance. It also makes it easier to spot mistakes—like accidentally registering a scoped service when you meant singleton, or forgetting to register a dependency altogether.

Configuration typically happens here too. Options patterns work beautifully:

csharp
builder.Services.Configure<DatabaseOptions>(
    builder.Configuration.GetSection("Database"));
builder.Services.Configure<CacheOptions>(
    builder.Configuration.GetSection("Cache"));

Then inject IOptions<DatabaseOptions> where you need it. This keeps configuration separate from logic and makes testing simpler.

Build() locks the container—treat it seriously

Once you call builder.Build(), the dependency injection container is locked. No new service registrations are allowed after this point.

csharp
var app = builder.Build();  // Container is now locked

Attempt to register a service after Build(), and you'll get an exception:

csharp
var app = builder.Build();
app.Services.AddScoped<IUserRepository, UserRepository>();  // Throws

Why the hard boundary? The DI container needs to validate the dependency graph. If you tried to add registrations after the app was running, the container couldn't guarantee that every dependency could be resolved. By locking at Build(), ASP.NET Core ensures that either all dependencies are satisfied at startup, or you fail fast with a clear error.

In practice, this means all your builder.Services.Add* calls must come before Build(). If you're conditionally registering services based on configuration, read the configuration before you build:

csharp
var useRedis = builder.Configuration.GetValue<bool>("Cache:UseRedis");

if (useRedis)
{
    builder.Services.AddStackExchangeRedis(options =>
        options.Configuration = builder.Configuration.GetConnectionString("Redis"));
}
else
{
    builder.Services.AddMemoryCache();
}

var app = builder.Build();  // Now the choice is locked in

The app object you get back isn't just a marker—it's your WebApplication instance, the object that represents your running server. Its Services property gives read-only access to the container. You can resolve services from it, but only for initialization logic that runs before the server listens. Controllers and middleware get dependencies injected automatically by the framework, not by you calling the container directly.

Middleware order between Build() and Run(): The request pipeline

After Build(), everything between there and app.Run() is middleware registration. The order you add middleware is the order requests pass through it—going in one direction, then coming back out in reverse.

csharp
var app = builder.Build();

app.UseHttpsRedirection();
app.UseAuthentication();
app.UseAuthorization();
app.MapControllers();

app.Run();

Think of middleware as a stack of filters. A request enters from the top, potentially passes through many middleware layers, hits your controller or endpoint, then bubbles back up through those same layers (now handling the response). The order matters intensely:

  • HTTPS redirection: Should come first so unsecured requests get redirected before anything else runs.
  • Authentication: Should come before authorization. You can't check if a user is allowed to do something until you know who they are.
  • Authorization: Comes after authentication, blocking requests from users without required claims or roles.
  • Your endpoints: Controllers, Minimal APIs—whatever handles the business logic—come last. By the time a request reaches here, all security and plumbing middleware has validated it.

Get this wrong, and you'll have security holes or confusing failures. We've seen production incidents where authorization middleware was registered after the endpoint mapping, making authorization checks dead code. The request never even reached them.

csharp
// WRONG: Authorization never runs
app.MapControllers();
app.UseAuthorization();  // This runs after the response is already committed
app.Run();
csharp
// CORRECT: Request flows through authorization first
app.UseAuthentication();
app.UseAuthorization();
app.MapControllers();
app.Run();

Custom middleware follows the same principle. If you write a logging middleware, do you want it to log every request, or only requests that passed authorization? The answer determines where you register it.

csharp
app.Use(async (context, next) =>
{
    Console.WriteLine($"Request: {context.Request.Path}");
    await next();
    Console.WriteLine($"Response: {context.Response.StatusCode}");
});

app.UseAuthentication();
app.UseAuthorization();
app.MapControllers();

That middleware logs everything. If you move it after authorization, it only logs authorized requests, and you lose visibility into rejected ones. Context matters.

Common mistakes

  • Registering a scoped or transient service, then trying to use it in a singleton: This creates a captive dependency. The singleton holds a reference to the scoped service forever, defeating the purpose of scopes. The scoped service never gets disposed at the end of the request.
  • Forgetting to register a service: You'll get a clear error at runtime: "Unable to resolve service". Always check that every dependency has an entry in builder.Services.
  • Middleware in the wrong order: Authorization after endpoint mapping, or logging before authentication—subtle mistakes that make security or observability silently fail.
  • Putting business logic in Program.cs: It belongs in services and controllers. Keep Program.cs clean and focused on wiring.
  • Not grouping related registrations: When services are scattered, the next person (or you in six months) can't see the architecture clearly.

Key takeaways

  • CreateBuilder() sets up configuration, logging, and DI with sensible defaults—you don't have to wire those yourself.
  • builder.Services is where you register every dependency your application needs, organized by concern and using appropriate lifetimes.
  • Build() locks the container. All service registrations must happen before this call. After it, the dependency graph is validated and fixed.
  • Middleware order between Build() and Run() is critical. Requests flow top-to-bottom through the pipeline, responses flow bottom-to-top. Security and authentication middleware must come before authorization and endpoints.
  • Keep Program.cs focused on composition. Business logic belongs elsewhere. This file should tell you how the application is wired, nothing more.
ShareXLinkedInReddit

Found this useful? Support the writing.

Buy me a coffee
Written by
Expona Admin
Site administrator

Comments

Loading comments…

Leave a comment

Comments are reviewed before they appear. Be kind and stay on topic.

Related articles