DEV Community

Cover image for Register, Then Run: The Discipline Hiding in Program.cs
Kazem
Kazem

Posted on

Register, Then Run: The Discipline Hiding in Program.cs

The first time I tried to register a service after builder.Build() in ASP.NET Core, I got an error that made no sense to me. The container was already sealed, it said. I'd spent years in Laravel, where you can bind things into the container more or less whenever you feel like it, and my instinct was to go looking for the service registration wherever the code needed it. ASP.NET Core doesn't work that way, and once I understood why, a handful of other things that had confused me stopped being confusing.

Two phases, strictly ordered

Here's what a minimal Program.cs looks like:

var builder = WebApplication.CreateBuilder(args);

// ---------- 1. REGISTRATION: build the DI container + config ----------
builder.Services.AddDbContext<AppDbContext>(o =>
    o.UseNpgsql(builder.Configuration.GetConnectionString("Default")));
builder.Services.AddScoped<IPostService, PostService>();
builder.Services.AddProblemDetails();
builder.Services.AddOpenApi();
builder.Services.AddAuthentication().AddJwtBearer();
builder.Services.AddAuthorization();
builder.Services.AddHealthChecks().AddDbContextCheck<AppDbContext>();

var app = builder.Build();   // container is now SEALED — no more registrations

// ---------- 2. PIPELINE: order is behaviour ----------
app.UseExceptionHandler();
app.UseHttpsRedirection();
app.UseAuthentication();
app.UseAuthorization();

// ---------- 3. ENDPOINTS ----------
app.MapControllers();
app.MapHealthChecks("/health/ready");
app.MapOpenApi();

app.Run();
Enter fullscreen mode Exit fullscreen mode

Everything on builder.Services happens before builder.Build(). Everything on app happens after. You register everything, build the container once, and only then configure the pipeline. There's no Startup.cs anymore, either. If you're reading a tutorial with Startup.ConfigureServices, it's pre-.NET 6.

This isn't a syntax quirk. It's the same register-then-run discipline you'll run into again as soon as you touch DI lifetimes or configuration, and skipping past it is exactly how people carry over habits from more dynamic frameworks and get burned.

The captive dependency trap

DI lifetimes in ASP.NET Core come in three flavors:

Lifetime One instance per Use for
Singleton Application Stateless services, caches, config, IHttpClientFactory
Scoped HTTP request DbContext, repositories, unit of work, anything request-specific
Transient Every resolution Cheap, stateless helpers

The DI scope is created per request, and everything registered as Scoped (your DbContext, your unit of work) lives and dies with it. The trap is injecting a Scoped service into a Singleton. The singleton is built once, so it captures whatever instance of the scoped service happened to be around at that moment (usually the first request's) and holds onto it forever. Your DbContext becomes a shared, thread-unsafe, ever-growing object that nobody intended to create.

The default container throws on this in development, via ValidateScopes. Keep that on, and turn on ValidateOnBuild too so CI catches it before it ships:

builder.Host.UseDefaultServiceProvider((ctx, o) =>
{
    o.ValidateScopes = true;
    o.ValidateOnBuild = true;
});
Enter fullscreen mode Exit fullscreen mode

If you genuinely need scoped work inside a singleton or a BackgroundService, you don't fight the lifetime. You create a scope explicitly:

using var scope = _serviceScopeFactory.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
Enter fullscreen mode Exit fullscreen mode

Configuration

Configuration in ASP.NET Core is layered: appsettings.json, then appsettings.{Environment}.json, then user secrets in dev, then environment variables, then command line, each one overriding the last.

Binding options follows a simple rule: validate at startup, not at first use.

// Bind + validate at startup, not at first use
builder.Services.AddOptions<SmtpOptions>()
    .Bind(builder.Configuration.GetSection("Smtp"))
    .ValidateDataAnnotations()
    .ValidateOnStart();

// Consume
public sealed class Mailer(IOptions<SmtpOptions> options) { ... }
Enter fullscreen mode Exit fullscreen mode

There are three ways to consume it, and picking the wrong one is another version of the captive dependency problem: IOptions<T> is a singleton snapshot, taken once and frozen. IOptionsSnapshot<T> re-reads per request. IOptionsMonitor<T> gives you change notifications and is the only one of the three that's safe to use inside a singleton. Never put secrets in appsettings.json. Use user secrets locally, and a real secret store in production.

What this means for project structure

A layered solution (Domain, Application, Infrastructure, Api) enforces a dependency rule: arrows point inward, Domain knows nothing about EF Core or HTTP. If your Domain project needs a using Microsoft.EntityFrameworkCore;, the layering is broken.

Two things I've learned from actually building this, not from a tutorial: four projects is the ceiling for a small service, not the floor. A single well-organized project with folders is fine below roughly ten endpoints, and it's much easier to move around later. And for feature-heavy apps, vertical slices (a folder per feature containing its endpoint, handler, DTOs, and validator) tend to scale better in practice than horizontal layers. Layers keep dependencies honest; slices keep changes local. Pick one on purpose, and write down why, because you'll want to remember the reasoning the next time someone asks why the project isn't laid out the "standard" way.

Top comments (0)