Middleware & Configuration

Writing custom middleware, appsettings.json, IConfiguration, and environment-specific config.

Writing custom middleware

Middleware is just a delegate (or a class) that receives the current HttpContext and a reference to "the next thing in the pipeline." The simplest form is an inline lambda registered with app.Use:

C#
app.Use(async (context, next) =>
{
    var start = DateTime.UtcNow;

    await next(context);   // pass control to the rest of the pipeline

    var elapsed = DateTime.UtcNow - start;
    Console.WriteLine($"{context.Request.Method} {context.Request.Path} " +
                       $"-> {context.Response.StatusCode} ({elapsed.TotalMilliseconds:F0}ms)");
});

Note the call to await next(context) sits in the middle of the method — code before it runs on the way in (request phase), code after it runs on the way out (response phase), once every inner middleware and your endpoint have already run.

A reusable middleware class

For anything beyond a couple of lines, write a proper middleware class instead of an inline lambda — it's testable and keeps Program.cs clean:

C#
public class RequestLoggingMiddleware
{
    private readonly RequestDelegate _next;
    private readonly ILogger<RequestLoggingMiddleware> _logger;

    // The container injects RequestDelegate (the next middleware) and any
    // registered service (ILogger here) into the constructor automatically.
    public RequestLoggingMiddleware(RequestDelegate next, ILogger<RequestLoggingMiddleware> logger)
    {
        _next = next;
        _logger = logger;
    }

    public async Task InvokeAsync(HttpContext context)
    {
        var start = DateTime.UtcNow;

        await _next(context);

        var elapsed = DateTime.UtcNow - start;
        _logger.LogInformation("{Method} {Path} -> {StatusCode} ({ElapsedMs}ms)",
            context.Request.Method,
            context.Request.Path,
            context.Response.StatusCode,
            elapsed.TotalMilliseconds);
    }
}

Register it with a small extension method — the idiomatic way to make custom middleware read like a built-in one:

C#
public static class RequestLoggingMiddlewareExtensions
{
    public static IApplicationBuilder UseRequestLogging(this IApplicationBuilder app)
    {
        return app.UseMiddleware<RequestLoggingMiddleware>();
    }
}
C#
var app = builder.Build();

app.UseRequestLogging();   // reads exactly like the built-in app.UseXxx() calls
app.UseRouting();
app.MapControllers();

app.Run();

Configuration: appsettings.json and IConfiguration

ASP.NET Core reads configuration from multiple layered sources — appsettings.json is just the most common one. A typical file:

JSON
{
  "Logging": {
    "LogLevel": {
      "Default": "Information",
      "Microsoft.AspNetCore": "Warning"
    }
  },
  "ConnectionStrings": {
    "DefaultConnection": "Server=localhost;Database=Shop;Trusted_Connection=True;"
  },
  "AppSettings": {
    "SiteName": "NOA Labs Shop",
    "MaxItemsPerPage": 25
  }
}

Read it via the injected IConfiguration service — available everywhere without any extra registration:

C#
app.MapGet("/config-demo", (IConfiguration config) =>
{
    var siteName = config["AppSettings:SiteName"];                 // colon-separated path
    var maxItems = config.GetValue<int>("AppSettings:MaxItemsPerPage");
    var connStr  = config.GetConnectionString("DefaultConnection"); // shortcut for ConnectionStrings:*

    return Results.Ok(new { siteName, maxItems, connStr });
});

For anything beyond a couple of loose values, bind a whole section to a strongly-typed class instead of stringly-typed lookups:

C#
public class AppSettings
{
    public string SiteName { get; set; } = "";
    public int MaxItemsPerPage { get; set; }
}
C#
builder.Services.Configure<AppSettings>(builder.Configuration.GetSection("AppSettings"));
C#
app.MapGet("/config-demo", (IOptions<AppSettings> options) =>
{
    var settings = options.Value;
    return Results.Ok(settings);
});

Environment-specific configuration

appsettings.json is the base layer. appsettings.{Environment}.json overlays and overrides specific keys for that environment, and is loaded automatically based on the ASPNETCORE_ENVIRONMENT environment variable:

JSON
// appsettings.Development.json — only loaded when ASPNETCORE_ENVIRONMENT=Development
{
  "Logging": {
    "LogLevel": {
      "Default": "Debug"
    }
  },
  "ConnectionStrings": {
    "DefaultConnection": "Server=localhost;Database=Shop_Dev;Trusted_Connection=True;"
  }
}

The full configuration precedence order (later sources override earlier ones):

  1. appsettings.json
  2. appsettings.{Environment}.json
  3. User secrets (Development only)
  4. Environment variables
  5. Command-line arguments

This is why production secrets (real connection strings, API keys) should never be hardcoded into appsettings.json committed to source control — they belong in environment variables or a secrets manager, which sit later in the precedence chain and naturally override any placeholder committed to the repo.

Common mistakes

  • Committing real secrets (passwords, API keys) into appsettings.json — anything checked into source control should be a placeholder, with real values supplied via environment variables or a secrets manager.
  • Forgetting that IConfiguration keys are colon-separated for nested JSON ("AppSettings:SiteName"), not dot-separated.
  • Writing custom middleware as a class but forgetting the RequestDelegate _next constructor parameter, or forgetting to call await _next(context) — the pipeline simply stops there and every later middleware/endpoint never runs.

Interview questions

Q: What does calling next() inside a middleware actually do? It invokes the next middleware in the pipeline (or the endpoint itself, if this is the last one before routing dispatch). Code before that call runs as the request flows in; code after it runs as the response flows back out — this is why middleware order in Program.cs directly determines both request and response processing order.

Q: How does ASP.NET Core decide which appsettings.*.json file to load? It always loads appsettings.json first, then appsettings.{ASPNETCORE_ENVIRONMENT}.json (e.g. appsettings.Development.json) if that environment variable is set and a matching file exists, with the environment-specific file's values overriding the base file's for any overlapping keys.

Q: Where should production secrets live if not in appsettings.json? In environment variables, a secrets manager (like Azure Key Vault or AWS Secrets Manager), or — for local development only — the .NET user-secrets tool (dotnet user-secrets), which stores values outside the project directory entirely so they can never be accidentally committed.