Skip to content

Middleware Interview Questions & Answers

15 questions Updated 2026-06-23 Share:

ASP.NET Core middleware interview questions — request pipeline, Use vs Run vs Map, custom middleware, ordering, and short-circuiting.

Read the in-depth guideHow the ASP.NET Core Middleware Pipeline Works(opens in new tab)
15 of 15

Middleware are components assembled into a pipeline that processes every HTTP request and response. Each component can execute logic before and after calling the next component, inspect or mutate the request/response, or short-circuit the pipeline by not calling the next component.

var builder = WebApplication.CreateBuilder(args);
var app = builder.Build();

// Each Use() adds a middleware component to the pipeline:
app.Use(async (context, next) =>
{
    // Before: runs on the way IN (request)
    Console.WriteLine($"→ {context.Request.Path}");

    await next(context);   // call the next middleware

    // After: runs on the way OUT (response)
    Console.WriteLine($"← {context.Response.StatusCode}");
});

app.MapGet("/", () => "Hello World");

app.Run(); // starts the pipeline

The pipeline is a chain of delegates — each middleware receives an HttpContext and a RequestDelegate (next). The ASP.NET Core host calls the first middleware; it calls next, which calls the second, and so on. The response flows back through each component in reverse order.

Rule of thumb: Think of the middleware pipeline as an onion — request goes inward through each layer, response comes back outward through the same layers in reverse.

  • Use — adds a middleware that calls next to continue the pipeline.
  • Run — adds a terminal middleware that never calls next; it ends the pipeline.
  • Mapbranches the pipeline for requests matching a path prefix; the branch runs independently and does not rejoin the main pipeline.
app.Use(async (context, next) =>
{
    // Runs for ALL requests; calls next to continue
    context.Items["start"] = DateTime.UtcNow;
    await next(context);
});

app.Map("/health", healthApp =>
{
    // Branch: only requests to /health enter here
    healthApp.Run(async context =>
    {
        // Terminal: returns immediately; never calls next
        await context.Response.WriteAsync("OK");
    });
});

// Main pipeline continues for non-/health requests:
app.Run(async context =>
{
    // Terminal middleware for everything else
    await context.Response.WriteAsync("Hello");
});

MapWhen is a more flexible variant that branches on any predicate:

app.MapWhen(
    ctx => ctx.Request.Headers.ContainsKey("X-Special"),
    specialApp => specialApp.Run(async ctx =>
        await ctx.Response.WriteAsync("special path")));

Rule of thumb: Use Use for cross-cutting concerns that must wrap all requests. Use Run only at the end of a pipeline branch. Use Map to isolate sub-pipelines for specific path prefixes.

Middleware runs in registration order on the request path and in reverse order on the response path. Ordering determines which component sees the request first and which wraps which. Getting it wrong causes security holes or broken behavior.

// Microsoft's recommended ordering for a typical web API:
app.UseExceptionHandler("/error");   // 1. Catch all unhandled exceptions
app.UseHsts();                       // 2. Add Strict-Transport-Security header
app.UseHttpsRedirection();           // 3. Redirect HTTP → HTTPS
app.UseStaticFiles();                // 4. Serve static files (no auth needed)
app.UseRouting();                    // 5. Match routes (populates endpoint metadata)
app.UseCors();                       // 6. Apply CORS policy (must be after routing)
app.UseAuthentication();             // 7. Identify the user
app.UseAuthorization();              // 8. Enforce access rules
app.UseOutputCache();                // 9. Cache responses (must be after auth)
app.MapControllers();                // 10. Dispatch to controller actions

Wrong ordering examples:

  • UseAuthentication before UseRouting means auth runs even for unmatched routes.
  • UseCors before UseRouting means CORS cannot read endpoint metadata to apply per-endpoint policies.
  • UseStaticFiles after UseAuthentication applies auth to static asset requests (usually unnecessary overhead).

Rule of thumb: Exception handling goes first. Static files go before auth. Routing before CORS. Auth before authorization. Dispatch (MapControllers) goes last.

Two styles: convention-based (POCO class) and interface-based (IMiddleware). Convention-based is more common and requires no registration in DI as a singleton.

// Convention-based: constructor takes RequestDelegate; InvokeAsync takes HttpContext
public class RequestTimingMiddleware
{
    private readonly RequestDelegate _next;
    private readonly ILogger<RequestTimingMiddleware> _logger;

    public RequestTimingMiddleware(RequestDelegate next,
        ILogger<RequestTimingMiddleware> logger)
    {
        _next = next;
        _logger = logger;
    }

    public async Task InvokeAsync(HttpContext context)
    {
        var sw = Stopwatch.StartNew();
        await _next(context);          // call the rest of the pipeline
        sw.Stop();
        _logger.LogInformation("{Method} {Path} completed in {Ms} ms",
            context.Request.Method,
            context.Request.Path,
            sw.ElapsedMilliseconds);
    }
}

// Extension method for clean registration:
public static class RequestTimingMiddlewareExtensions
{
    public static IApplicationBuilder UseRequestTiming(
        this IApplicationBuilder app) =>
        app.UseMiddleware<RequestTimingMiddleware>();
}

// In Program.cs:
app.UseRequestTiming();

The IMiddleware style is created per-request from DI (supports scoped dependencies):

public class ScopedMiddleware : IMiddleware
{
    private readonly IScopedService _svc; // scoped dep — works with IMiddleware

    public ScopedMiddleware(IScopedService svc) => _svc = svc;

    public async Task InvokeAsync(HttpContext context, RequestDelegate next)
    {
        _svc.DoSomething();
        await next(context);
    }
}

// Must be registered in DI:
builder.Services.AddTransient<ScopedMiddleware>();
app.UseMiddleware<ScopedMiddleware>();

Rule of thumb: Use convention-based middleware for singleton / transient dependencies. Use IMiddleware when you need scoped services injected directly into the middleware constructor.

Short-circuiting means a middleware does not call next — it handles the request entirely and writes the response itself, preventing later middleware from running. This is how authentication middleware rejects unauthorized requests before they reach the controller.

app.Use(async (context, next) =>
{
    // Check API key in header
    if (!context.Request.Headers.TryGetValue("X-Api-Key", out var key)
        || key != "secret-key")
    {
        // Short-circuit: respond 401 without calling next
        context.Response.StatusCode = 401;
        await context.Response.WriteAsync("Unauthorized");
        return; // ← critical: do NOT call next
    }

    await next(context); // valid key — continue to next middleware
});

// Controller only runs if the API key was valid:
app.MapControllers();

Common scenarios for short-circuiting:

  • Authentication / authorization failures — return 401 / 403.
  • Health checks — return 200 OK immediately without running the full pipeline.
  • Rate limiting — return 429 Too Many Requests.
  • CORS preflight — handle OPTIONS requests without business logic.
  • Request validation — return 400 Bad Request for malformed input.

Rule of thumb: Short-circuit as early in the pipeline as possible to avoid unnecessary work. Always set the correct HTTP status code before returning without calling next.

ASP.NET Core provides two built-in options: UseExceptionHandler for production (re-executes a different endpoint) and UseDeveloperExceptionPage for development (shows a detailed stack trace). You can also write your own.

if (app.Environment.IsDevelopment())
    app.UseDeveloperExceptionPage(); // shows full stack trace — dev only!
else
    app.UseExceptionHandler("/error"); // re-executes /error endpoint — production

// /error endpoint reads the stored exception from HttpContext:
app.Map("/error", (HttpContext ctx) =>
{
    var ex = ctx.Features.Get<IExceptionHandlerFeature>()?.Error;
    return Results.Problem(
        title: "An error occurred",
        detail: ex?.Message,
        statusCode: 500);
});

Custom exception middleware — catches exceptions from any later middleware:

public class GlobalExceptionMiddleware
{
    private readonly RequestDelegate _next;
    private readonly ILogger _logger;

    public GlobalExceptionMiddleware(RequestDelegate next,
        ILogger<GlobalExceptionMiddleware> logger)
    {
        _next = next; _logger = logger;
    }

    public async Task InvokeAsync(HttpContext context)
    {
        try
        {
            await _next(context); // run the rest of the pipeline
        }
        catch (Exception ex)
        {
            _logger.LogError(ex, "Unhandled exception for {Path}",
                context.Request.Path);

            context.Response.StatusCode = 500;
            context.Response.ContentType = "application/json";
            await context.Response.WriteAsJsonAsync(
                new { error = "An unexpected error occurred." });
        }
    }
}

Rule of thumb: Always register exception-handling middleware first in the pipeline so it wraps every subsequent component and can catch any unhandled exception.

Middleware operates at the HTTP pipeline level — it sees raw HttpContext and runs for every request, including static files, health checks, and non-MVC routes. Action filters are part of the MVC framework and run only for controller actions; they have access to ActionContext, model binding results, and action arguments.

// Middleware: applies to every request, no MVC context
app.Use(async (ctx, next) =>
{
    // ctx.Request, ctx.Response — raw HTTP only
    // No knowledge of controllers, actions, or models
    await next(ctx);
});

// Action filter: runs only for matched MVC endpoints
public class ValidateModelFilter : IActionFilter
{
    public void OnActionExecuting(ActionExecutingContext context)
    {
        // Has access to model state, action arguments, controller
        if (!context.ModelState.IsValid)
            context.Result = new BadRequestObjectResult(context.ModelState);
    }

    public void OnActionExecuted(ActionExecutedContext context) { }
}

// Register globally:
builder.Services.AddControllers(options =>
    options.Filters.Add<ValidateModelFilter>());
Aspect Middleware Action Filter
Scope All requests MVC actions only
Context HttpContext ActionContext, model state
Ordering Pipeline order Filter pipeline (Auth→Resource→Action→Result)
Best for CORS, logging, auth Validation, audit, action-level cross-cutting

Rule of thumb: Use middleware for concerns that apply to all HTTP traffic (logging, CORS, auth, rate limiting). Use filters when you need MVC context (model state, action arguments, controller metadata).

ASP.NET Core ships with a rich set of built-in middleware for common scenarios:

app.UseExceptionHandler("/error");      // Global exception handling
app.UseHsts();                          // HTTP Strict Transport Security
app.UseHttpsRedirection();              // HTTP → HTTPS redirect
app.UseStaticFiles();                   // Serve wwwroot static assets
app.UseRouting();                       // Endpoint routing
app.UseCors("MyPolicy");               // Cross-Origin Resource Sharing
app.UseAuthentication();               // JWT / cookie auth
app.UseAuthorization();                // Policy enforcement
app.UseRateLimiter();                  // Rate limiting (.NET 7+)
app.UseOutputCache();                  // Response caching (.NET 7+)
app.UseResponseCompression();          // Gzip / Brotli compression
app.UseSession();                      // Session state
app.UseRequestLocalization();          // i18n / locale

The UseStaticFiles middleware serves files from wwwroot by default:

// Serve files from a custom directory:
app.UseStaticFiles(new StaticFileOptions
{
    FileProvider = new PhysicalFileProvider(
        Path.Combine(builder.Environment.ContentRootPath, "uploads")),
    RequestPath = "/uploads"
});

Rule of thumb: Rely on built-in middleware before writing custom components — they are battle-tested, well-configured, and maintained by Microsoft. Only write custom middleware for genuinely application-specific concerns.

In convention-based middleware the middleware class is instantiated once (singleton lifetime). Singleton services can be injected via the constructor. Scoped and transient services must be injected via InvokeAsync parameters — ASP.NET Core resolves them from the per-request scope automatically.

public class AuditMiddleware
{
    private readonly RequestDelegate _next;
    private readonly IConfiguration _config; // singleton — safe in constructor

    public AuditMiddleware(RequestDelegate next, IConfiguration config)
    {
        _next = next;
        _config = config;
    }

    // Scoped service injected via parameter — fresh instance per request:
    public async Task InvokeAsync(HttpContext context, IAuditService auditService)
    {
        await auditService.LogRequestAsync(context.Request.Path);
        await _next(context);
    }
}

Injecting a scoped service into the constructor of convention-based middleware creates a captive dependency — the scoped service lives for the entire app lifetime, causing shared state bugs across requests.

Rule of thumb: Singleton deps → constructor. Scoped / transient deps → InvokeAsync parameters. Alternatively, use IMiddleware which is resolved per-request and supports any lifetime in the constructor.

UseResponseCaching (all .NET versions) adds standard HTTP cache headers (Cache-Control) and caches responses in memory that have those headers. It only caches GET/HEAD requests with successful 2xx status codes and does not cache when the request includes Authorization headers by default.

// Startup:
builder.Services.AddResponseCaching();
app.UseResponseCaching();   // must be before routing

// Controller action:
[HttpGet("products")]
[ResponseCache(Duration = 60, Location = ResponseCacheLocation.Any)]
public IActionResult GetProducts() => Ok(_products);
// Sets: Cache-Control: public, max-age=60

UseOutputCache (.NET 7+) is a newer, more powerful replacement:

builder.Services.AddOutputCache(options =>
    options.AddPolicy("products", p => p.Expire(TimeSpan.FromMinutes(1))));

app.UseOutputCache();

app.MapGet("products", () => products).CacheOutput("products");
// Supports cache invalidation, vary-by-query, tags, and custom stores
Feature ResponseCaching OutputCache
Standard HTTP/1.1 Cache-Control Server-side only
Invalidation None Tag-based
Vary-by Headers Query, headers, claims, custom
.NET version All .NET 7+

Rule of thumb: For new projects on .NET 7+, prefer UseOutputCache — it gives fine-grained control, tag-based invalidation, and works correctly with authenticated endpoints. Use UseResponseCaching only for simple public CDN-friendly responses.

CORS (Cross-Origin Resource Sharing) is enforced by the browser for cross-origin requests. The ASP.NET Core CORS middleware adds the necessary Access-Control-Allow-* response headers.

// Register CORS with a named policy:
builder.Services.AddCors(options =>
{
    options.AddPolicy("AllowFrontend", policy =>
        policy.WithOrigins("https://app.example.com")
              .WithMethods("GET", "POST", "PUT", "DELETE")
              .WithHeaders("Content-Type", "Authorization")
              .AllowCredentials());

    // Development: allow any origin (NEVER use in production):
    options.AddPolicy("DevAllowAll", policy =>
        policy.AllowAnyOrigin()
              .AllowAnyMethod()
              .AllowAnyHeader());
});

// Apply: MUST be between UseRouting and UseAuthorization
app.UseRouting();
app.UseCors("AllowFrontend");
app.UseAuthorization();

// Per-endpoint override:
app.MapGet("/public", () => "public")
   .RequireCors("DevAllowAll");

// Disable CORS for a specific endpoint:
app.MapGet("/internal", () => "internal")
   .RequireCors(builder => builder.DisallowCredentials());

The preflight OPTIONS request is handled automatically by UseCors — you do not need a separate OPTIONS action in your controller.

Rule of thumb: Define tight CORS policies per environment — never use AllowAnyOrigin with AllowCredentials (the spec forbids it and ASP.NET Core throws). Register UseCors between UseRouting and UseAuthorization.

A request travels through the pipeline in a strict sequence:

HTTP Request
     │
     ▼
┌─────────────────────────┐
│ ExceptionHandler        │ ← wraps all; catches any exception
│  ┌───────────────────┐  │
│  │ HSTS / HTTPS      │  │ ← security headers
│  │  ┌─────────────┐  │  │
│  │  │ StaticFiles │  │  │ ← served without auth
│  │  │  ┌────────┐ │  │  │
│  │  │  │Routing │ │  │  │ ← matches endpoint, populates metadata
│  │  │  │  ┌───┐ │ │  │  │
│  │  │  │  │Auth│ │ │  │  │ ← authn + authz
│  │  │  │  │ ┌─┐│ │ │  │  │
│  │  │  │  │ │EP│ │ │  │  │ ← endpoint (controller action / minimal API)
│  │  │  │  │ └─┘│ │ │  │  │
│  │  │  │  └───┘ │ │  │  │
│  │  │  └────────┘ │  │  │
│  │  └─────────────┘  │  │
│  └───────────────────┘  │
└─────────────────────────┘
     │
     ▼
HTTP Response (flows back outward through each layer)

Each box represents a middleware. The response flows back outward through each layer in reverse, allowing each component to inspect or modify the response.

// Simplified but accurate lifecycle in code:
app.UseExceptionHandler("/error");   // outer: catches exceptions
app.UseHttpsRedirection();
app.UseStaticFiles();
app.UseRouting();
app.UseAuthentication();
app.UseAuthorization();
app.MapControllers();               // inner: business logic

Rule of thumb: The request flows inward (top → bottom in registration order), the response flows outward (bottom → top). Components registered early have the most control and the most responsibility.

Rate limiting (.NET 7+) is built into ASP.NET Core via Microsoft.AspNetCore.RateLimiting. You define one or more policies, register them with AddRateLimiter, and apply the middleware with UseRateLimiter.

using System.Threading.RateLimiting;

builder.Services.AddRateLimiter(options =>
{
    // Fixed window: max 100 requests per 60-second window per client IP
    options.AddFixedWindowLimiter("fixed", o =>
    {
        o.PermitLimit         = 100;
        o.Window              = TimeSpan.FromSeconds(60);
        o.QueueProcessingOrder = QueueProcessingOrder.OldestFirst;
        o.QueueLimit          = 10; // queue up to 10 excess requests
    });

    // Sliding window — smoother than fixed; avoids burst at window boundary
    options.AddSlidingWindowLimiter("sliding", o =>
    {
        o.PermitLimit         = 100;
        o.Window              = TimeSpan.FromSeconds(60);
        o.SegmentsPerWindow   = 6; // 10-second segments
        o.QueueProcessingOrder = QueueProcessingOrder.OldestFirst;
        o.QueueLimit          = 0;
    });

    // Token bucket — allows bursts up to bucket size
    options.AddTokenBucketLimiter("api", o =>
    {
        o.TokenLimit          = 50;
        o.TokensPerPeriod     = 10;
        o.ReplenishmentPeriod = TimeSpan.FromSeconds(10);
        o.QueueProcessingOrder = QueueProcessingOrder.OldestFirst;
        o.QueueLimit          = 0;
    });

    // 429 response when limit exceeded:
    options.RejectionStatusCode = StatusCodes.Status429TooManyRequests;
});

// Register in pipeline AFTER UseRouting:
app.UseRateLimiter();

// Apply a policy to a specific endpoint:
app.MapGet("/api/data", () => "data")
   .RequireRateLimiting("fixed");

// Apply globally to all controllers:
app.MapControllers().RequireRateLimiting("api");

// Disable on a specific action:
app.MapGet("/health", () => "OK")
   .DisableRateLimiting();

Rule of thumb: Prefer AddTokenBucketLimiter for APIs that need burst tolerance, and AddSlidingWindowLimiter for smooth, predictable throttling. Always set RejectionStatusCode = 429 and consider partition by user or IP for fairness.

Both UseWhen and MapWhen branch the middleware pipeline based on a predicate, but they differ in whether the branch rejoins the main pipeline:

  • UseWhen — runs extra middleware for matching requests, then rejoins the main pipeline after the branch, so subsequent middleware still runs.
  • MapWhen — creates a completely separate branch; matching requests never return to the main pipeline.
// UseWhen: add logging only for API requests, then continue main pipeline
app.UseWhen(
    ctx => ctx.Request.Path.StartsWithSegments("/api"),
    apiApp =>
    {
        apiApp.Use(async (context, next) =>
        {
            Console.WriteLine($"API request: {context.Request.Method} {context.Request.Path}");
            await next(context); // continue branch
        });
        // After the branch, the main pipeline continues — MapControllers still runs
    });

app.UseAuthentication();
app.UseAuthorization();
app.MapControllers(); // reached by ALL requests (both API and non-API)

// MapWhen: isolate /health — it never hits auth or controllers
app.MapWhen(
    ctx => ctx.Request.Path == "/health",
    healthApp =>
    {
        // Terminal for /health requests — does NOT rejoin main pipeline
        healthApp.Run(async ctx =>
            await ctx.Response.WriteAsync("Healthy"));
    });

// Good: UseWhen for cross-cutting concerns that still need the main pipeline
// Good: MapWhen for isolated sub-pipelines (health, metrics, webhooks)

Rule of thumb: Use UseWhen when you want conditional middleware but still need the rest of the pipeline (auth, routing, dispatch) to run. Use MapWhen when the branch is fully self-contained and must not fall through to the main pipeline.

Endpoint filters (ASP.NET Core 7+) are the minimal API equivalent of MVC action filters. They wrap a minimal API handler and can run logic before and after it executes, short-circuit with a different result, or modify the response.

// Inline endpoint filter:
app.MapPost("/orders", CreateOrder)
   .AddEndpointFilter(async (ctx, next) =>
   {
       // Before handler — validate request:
       if (!ctx.HttpContext.User.Identity!.IsAuthenticated)
           return Results.Unauthorized();

       var result = await next(ctx); // call the handler
       // After handler — inspect/modify result:
       return result;
   });

// Reusable filter class:
public class ValidationFilter<T> : IEndpointFilter
{
    public async ValueTask<object?> InvokeAsync(
        EndpointFilterInvocationContext ctx, EndpointFilterDelegate next)
    {
        // Find the first argument of type T:
        var arg = ctx.Arguments.OfType<T>().FirstOrDefault();
        if (arg is null)
            return Results.BadRequest("Missing request body");

        // Validate with DataAnnotations:
        var errors = new List<ValidationResult>();
        if (!Validator.TryValidateObject(arg, new ValidationContext(arg), errors, true))
            return Results.ValidationProblem(
                errors.ToDictionary(e => e.MemberNames.First(), e => new[] { e.ErrorMessage! }));

        return await next(ctx); // all valid — run handler
    }
}

// Apply typed validation filter to an endpoint:
app.MapPost("/products", (CreateProductDto dto) => CreateProduct(dto))
   .AddEndpointFilter<ValidationFilter<CreateProductDto>>();

// Apply to an entire group:
var orders = app.MapGroup("/orders").AddEndpointFilter<ValidationFilter<CreateOrderDto>>();
orders.MapPost("/", CreateOrder);
orders.MapPut("/{id:int}", UpdateOrder);
Aspect Endpoint Filter Action Filter
Target Minimal API endpoints MVC controller actions
Context EndpointFilterInvocationContext ActionExecutingContext
Groups Yes (MapGroup) Global / controller / action
Model state Manual validation Automatic with [ApiController]

Rule of thumb: Use endpoint filters in minimal APIs for cross-cutting concerns (validation, logging, rate limiting). Use action filters in controller-based APIs where you need access to ModelState, action descriptors, or MVC conventions.

More ways to practice

The self-quiz is live. Get notified when mock interviews and new question packs drop.

or
Join our WhatsApp Channel