ASP.NET Core
The framework that runs .NET on the server -- dependency injection, minimal APIs, the middleware pipeline, configuration and the Options pattern, and background services
ASP.NET Core
ASP.NET Core is the server-side framework that the rest of .NET assumes you are using. It is not a thin HTTP library bolted onto the runtime -- it is a host model, a dependency injection container, a configuration system, and a middleware pipeline that almost every .NET web service, worker, and gRPC endpoint is built on top of. Learn its primitives once and they transfer across MVC controllers, minimal APIs, Blazor, and background workers, because they are all wired through the same WebApplicationBuilder and the same service container.
Dependency Injection
.NET ships a built-in DI container, and the entire framework is built around it. You register services against the builder, the container resolves the dependency graph for you, and constructor injection becomes the default way every class gets what it needs. There is no third-party container to add for the common case -- the framework itself registers hundreds of services this way, and your code joins the same registry.
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddScoped<IUserStore, PostgresUserStore>();
builder.Services.AddSingleton<IClock, SystemClock>();
builder.Services.AddTransient<IEmailSender, SmtpEmailSender>();
builder.Services.AddDbContext<AppDbContext>();
var app = builder.Build();The three method names encode three lifetimes, and getting them right is most of the skill. Singleton is created once and lives for the whole application -- use it for stateless services and shared, thread-safe state like a clock or a cache. Scoped is created once per request (more precisely, per DI scope) and disposed when the request ends -- this is where DbContext and per-request units of work belong. Transient is created fresh on every resolution, so even two constructors in the same request get distinct instances; reach for it for cheap, stateless helpers.
The classic mistake is the captive dependency. If you inject a scoped service -- say AppDbContext -- into a singleton, the singleton captures that single DbContext instance for the lifetime of the application and reuses it across every request. DbContext is not thread-safe and tracks entity state, so this quietly corrupts data across concurrent requests and leaks memory as the change tracker grows without bound. The container will not stop you in production; enable scope validation in development and it will throw at startup instead. When a long-lived object genuinely needs a scoped service, inject IServiceProvider (or an IServiceScopeFactory) and create a scope explicitly -- the same pattern background services need below.
Minimal APIs
Minimal APIs let you define an HTTP endpoint with almost no ceremony. There is no controller class, no attribute soup, no base type to inherit -- you map a route to a lambda, and the framework handles routing, model binding, validation, and JSON serialization around it. For a small service or a microservice with a handful of endpoints, this is the most direct expression of "an HTTP request comes in, a response goes out" that the framework offers.
app.MapGet("/orders/{id}", async (string id, IOrderStore store, CancellationToken ct) =>
{
var order = await store.GetAsync(id, ct);
return order is null
? Results.NotFound()
: Results.Ok(order);
});
app.MapPost("/orders", async (CreateOrderRequest req, IOrderService svc, CancellationToken ct) =>
{
var order = await svc.CreateAsync(req, ct);
return Results.Created($"/orders/{order.Id}", order);
});
app.Run();The magic is in the parameter list, and it is worth understanding rather than memorizing. Each parameter is resolved by type and convention: id matches the {id} route token, req is a complex type with no route match so it is bound from the JSON body, store and svc are registered services so they come from DI, and CancellationToken is special-cased to the request's abort token. The Results helpers produce correct status codes and IResult responses without you touching HttpResponse directly.
The honest trade-off: controllers still offer more structure for large applications -- conventional grouping, filters, model binding attributes, and a familiar shape for teams with dozens of endpoints. Minimal APIs shine for services and microservices where the endpoint count is small and the ceremony of MVC buys you nothing. Use route groups (app.MapGroup("/orders")) to keep larger minimal-API surfaces organized before reaching for controllers.
The Middleware Pipeline
Every request flows through an ordered chain of middleware before it reaches your endpoint, and the response flows back out through the same chain in reverse. Each middleware is a function that receives the request and a next delegate; it can inspect or mutate the request, call next to pass control down the chain, inspect or mutate the response on the way back, or short-circuit by returning without calling next at all. This is the mechanism behind authentication, exception handling, HTTPS redirection, CORS, and routing -- they are all just middleware registered in order.
app.UseExceptionHandler("/error");
app.UseHttpsRedirection();
app.UseAuthentication();
app.UseAuthorization();
app.MapControllers();Order is not a detail -- it is the contract. Exception handling goes first so it wraps everything below it and can catch failures from any later middleware. HTTPS redirection comes early so insecure requests are bounced before any work happens. UseAuthentication must run before UseAuthorization, because you cannot authorize a principal you have not yet established, and both must run before the endpoints they protect. Routing (UseRouting, often implicit) determines which endpoint will run, which is why authorization can make decisions based on endpoint metadata.
Writing your own middleware is straightforward -- an inline Use for one-offs, or a class for anything reusable. The shape is always the same: do work, await next(context), do more work.
app.Use(async (context, next) =>
{
var sw = Stopwatch.StartNew();
await next(context);
sw.Stop();
context.Response.Headers["X-Response-Time-ms"] = sw.ElapsedMilliseconds.ToString();
});The built-in middleware covers most needs: UseExceptionHandler for centralized error responses, UseAuthentication and UseAuthorization for identity and access, UseRouting to match endpoints, plus UseCors, UseStaticFiles, and UseResponseCompression. Reach for custom middleware only when a cross-cutting concern genuinely spans every request -- correlation IDs, request logging, tenant resolution -- and keep request-specific logic in the endpoints themselves.
Configuration and the Options Pattern
Configuration in ASP.NET Core is layered. The framework reads from appsettings.json, then an environment-specific appsettings.{Environment}.json, then environment variables, then user secrets in development, then sources like Azure Key Vault -- and later sources override earlier ones key by key. All of this merges into a single IConfiguration that you rarely read directly. Instead, you bind sections of it to strongly-typed options classes, so the rest of the code depends on a clean object rather than stringly-typed lookups.
builder.Services.Configure<SmtpOptions>(builder.Configuration.GetSection("Smtp"));
public class SmtpEmailSender(IOptions<SmtpOptions> options)
{
private readonly SmtpOptions _opts = options.Value;
}There are three options interfaces, and the difference is about lifetime and reloads. IOptions<T> is a singleton snapshot computed once at startup -- it never changes, it is cheap, and it is the right default for configuration that does not change at runtime. IOptionsSnapshot<T> is scoped: it is recomputed per request and picks up configuration reloads, so use it when you want each request to see the latest values and you are inside a scoped or transient service. IOptionsMonitor<T> is a singleton that exposes the current value plus a change-notification callback, which makes it the only choice for long-lived singletons and background services that must react to config changes -- it is also the only one safe to inject into a singleton, since the scoped IOptionsSnapshot<T> would be a captive dependency there.
One rule that is non-negotiable: secrets do not belong in appsettings.json. Connection strings with passwords, API keys, and signing keys go in user secrets locally and in a real secret store -- Key Vault, environment variables injected by the platform, or your orchestrator's secret mechanism -- in deployed environments. appsettings.json is committed to source control, and anything in it is effectively public to everyone with repo access.
Background Services
Not all work fits the request/response model. Queue consumers, scheduled jobs, cache warmup, and outbox processors need to run independently of any incoming request, and ASP.NET Core hosts them through IHostedService and its convenient base class BackgroundService. You override ExecuteAsync, and the host starts it at application startup and signals it to stop -- via the stoppingToken -- during graceful shutdown.
public class CleanupWorker(IServiceProvider services) : BackgroundService
{
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
using var timer = new PeriodicTimer(TimeSpan.FromMinutes(5));
while (await timer.WaitForNextTickAsync(stoppingToken))
{
using var scope = services.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
// ... do scoped work
}
}
}PeriodicTimer is the modern way to write a polling loop. WaitForNextTickAsync(stoppingToken) awaits the next tick and returns false (rather than throwing) when the token is cancelled, so the while loop exits cleanly on shutdown without a try/catch around an OperationCanceledException. Pass the stopping token everywhere -- into the timer and into any I/O you await -- so the service actually stops when the host wants it to.
The scope is the important detail, and it is the captive-dependency problem again. Hosted services are resolved as singletons, so you cannot inject a scoped DbContext directly -- it would be captured for the life of the process. Instead inject IServiceProvider, and on each iteration call CreateScope() and resolve scoped services from that scope, disposing it when the work is done. This gives every tick a fresh DbContext with its own change tracker, exactly as a web request would get. For non-web hosts -- a pure queue consumer or a cron-style job with no HTTP surface -- the Worker Service template (Host.CreateApplicationBuilder) gives you the same hosting model without the web stack.
Sharp Edges
The recurring failures cluster around the same primitives. Middleware ordering bugs are the most insidious: register UseAuthorization after your endpoints and the requests are never authorized -- everything passes, and nothing in the logs looks wrong until someone notices the API is wide open. Resolving a scoped service inside a singleton -- a DbContext in a singleton service or a hosted service -- captures one instance across the whole app and corrupts state under concurrency; create a scope instead, and turn on scope validation in development so the container catches it at startup. IOptions<T> never reflects configuration reloads because it is a startup snapshot, so if you expect runtime changes to take effect, use IOptionsSnapshot<T> in scoped code or IOptionsMonitor<T> in singletons. And blocking on or ignoring the hosted-service stopping token means graceful shutdown hangs -- the host waits for ExecuteAsync to return, and if your loop never observes cancellation, deployments stall until the orchestrator kills the process. Honor the token, flow it through every await, and shutdown stays fast.