Section 3 of 5

Creating Custom Middleware

🎯 What You'll Learn

  • Three ways to create custom middleware
  • Inline middleware with app.Use()
  • Convention-based middleware classes
  • Factory-based middleware with IMiddleware
  • Dependency injection in middleware
  • Extension methods for middleware
  • Practical InvenTrack examples
  • Best practices

Three Ways to Create Middleware

Approach When to Use Complexity
Inline Simple, one-off logic Low
Convention-based Most common, reusable Medium
Factory-based Complex DI scenarios High

1. Inline Middleware

Use app.Use() for simple, one-off middleware:

Request Timing Middleware C#
app.Use(async (context, next) =>
{
    var stopwatch = Stopwatch.StartNew();
    
    await next();
    
    stopwatch.Stop();
    var elapsed = stopwatch.ElapsedMilliseconds;
    
    context.Response.Headers["X-Response-Time"] = $"{elapsed}ms";
});

API Key Validation

Inline API Key Check C#
app.Use(async (context, next) =>
{
    if (!context.Request.Headers.TryGetValue("X-API-Key", out var apiKey))
    {
        context.Response.StatusCode = 401;
        await context.Response.WriteAsync("API Key missing");
        return; // Short-circuit
    }
    
    if (apiKey != "secret-key-123")
    {
        context.Response.StatusCode = 403;
        await context.Response.WriteAsync("Invalid API Key");
        return;
    }
    
    await next();
});
💡 When to Use Inline

Inline middleware is great for:

  • Quick prototyping
  • Simple logic that won't be reused
  • Learning and experimentation

For production code, use convention-based or factory-based middleware.

2. Convention-Based Middleware

The most common approach. Create a class with a specific structure:

Basic Structure

Middleware/RequestLoggingMiddleware.cs C#
public class RequestLoggingMiddleware
{
    private readonly RequestDelegate _next;
    private readonly ILogger<RequestLoggingMiddleware> _logger;

    // Constructor: Inject RequestDelegate and singleton services
    public RequestLoggingMiddleware(
        RequestDelegate next,
        ILogger<RequestLoggingMiddleware> logger)
    {
        _next = next;
        _logger = logger;
    }

    // InvokeAsync: Process the request
    public async Task InvokeAsync(HttpContext context)
    {
        _logger.LogInformation(
            "Request: {Method} {Path}",
            context.Request.Method,
            context.Request.Path);

        await _next(context);

        _logger.LogInformation(
            "Response: {StatusCode}",
            context.Response.StatusCode);
    }
}

Registration

Program.cs C#
app.UseMiddleware<RequestLoggingMiddleware>();

Extension Method (Recommended)

Extensions/MiddlewareExtensions.cs C#
public static class MiddlewareExtensions
{
    public static IApplicationBuilder UseRequestLogging(
        this IApplicationBuilder app)
    {
        return app.UseMiddleware<RequestLoggingMiddleware>();
    }
}
Program.cs (Clean!) C#
app.UseRequestLogging();

Dependency Injection in Middleware

Constructor Injection (Singleton Services Only)

Constructor DI C#
public class MyMiddleware
{
    private readonly RequestDelegate _next;
    private readonly ILogger<MyMiddleware> _logger; // Singleton

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

InvokeAsync Injection (Scoped Services)

InvokeAsync DI C#
public class DatabaseHealthCheckMiddleware
{
    private readonly RequestDelegate _next;

    public DatabaseHealthCheckMiddleware(RequestDelegate next)
    {
        _next = next;
    }

    // Inject scoped services here!
    public async Task InvokeAsync(
        HttpContext context,
        InvenTrackDbContext dbContext) // Scoped!
    {
        if (context.Request.Path == "/health")
        {
            var canConnect = await dbContext.Database.CanConnectAsync();
            
            context.Response.StatusCode = canConnect ? 200 : 503;
            await context.Response.WriteAsync(
                canConnect ? "Healthy" : "Unhealthy");
            return;
        }
        
        await _next(context);
    }
}
⚠️ DI Rules for Middleware

Constructor: Only inject singleton services (ILogger, IConfiguration)
InvokeAsync: Can inject scoped services (DbContext, repositories)

Why? Middleware instances are created once (singleton lifetime), but InvokeAsync is called per request.

3. Factory-Based Middleware

Implement IMiddleware for complex DI scenarios:

Middleware/TenantMiddleware.cs C#
public class TenantMiddleware : IMiddleware
{
    private readonly ITenantService _tenantService;

    // Can inject scoped services in constructor!
    public TenantMiddleware(ITenantService tenantService)
    {
        _tenantService = tenantService;
    }

    public async Task InvokeAsync(HttpContext context, RequestDelegate next)
    {
        var tenantId = context.Request.Headers["X-Tenant-Id"].ToString();
        
        if (string.IsNullOrEmpty(tenantId))
        {
            context.Response.StatusCode = 400;
            await context.Response.WriteAsync("Tenant ID required");
            return;
        }
        
        await _tenantService.SetCurrentTenantAsync(tenantId);
        
        await next(context);
    }
}

Registration

Program.cs C#
// Register as scoped
builder.Services.AddScoped<TenantMiddleware>();

// Use it
app.UseMiddleware<TenantMiddleware>();

Complete InvenTrack Examples

Example 1: Request Timing Middleware

Middleware/RequestTimingMiddleware.cs C#
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 stopwatch = Stopwatch.StartNew();
        
        try
        {
            await _next(context);
        }
        finally
        {
            stopwatch.Stop();
            var elapsed = stopwatch.ElapsedMilliseconds;
            
            _logger.LogInformation(
                "{Method} {Path} completed in {Elapsed}ms with status {StatusCode}",
                context.Request.Method,
                context.Request.Path,
                elapsed,
                context.Response.StatusCode);
            
            context.Response.Headers["X-Response-Time-Ms"] = elapsed.ToString();
        }
    }
}

Example 2: API Key Validation Middleware

Middleware/ApiKeyMiddleware.cs C#
public class ApiKeyMiddleware
{
    private readonly RequestDelegate _next;
    private readonly IConfiguration _configuration;

    public ApiKeyMiddleware(
        RequestDelegate next,
        IConfiguration configuration)
    {
        _next = next;
        _configuration = configuration;
    }

    public async Task InvokeAsync(HttpContext context)
    {
        // Skip API key check for health endpoint
        if (context.Request.Path.StartsWithSegments("/health"))
        {
            await _next(context);
            return;
        }

        if (!context.Request.Headers.TryGetValue("X-API-Key", out var providedKey))
        {
            context.Response.StatusCode = 401;
            await context.Response.WriteAsJsonAsync(new
            {
                error = "API Key is missing"
            });
            return;
        }

        var validKey = _configuration["ApiKey"];
        
        if (providedKey != validKey)
        {
            context.Response.StatusCode = 403;
            await context.Response.WriteAsJsonAsync(new
            {
                error = "Invalid API Key"
            });
            return;
        }

        await _next(context);
    }
}

Example 3: Correlation ID Middleware

Middleware/CorrelationIdMiddleware.cs C#
public class CorrelationIdMiddleware
{
    private readonly RequestDelegate _next;
    private const string CorrelationIdHeader = "X-Correlation-Id";

    public CorrelationIdMiddleware(RequestDelegate next)
    {
        _next = next;
    }

    public async Task InvokeAsync(HttpContext context)
    {
        // Get or generate correlation ID
        var correlationId = context.Request.Headers[CorrelationIdHeader].FirstOrDefault()
            ?? Guid.NewGuid().ToString();

        // Store in HttpContext.Items for use in controllers/services
        context.Items[CorrelationIdHeader] = correlationId;

        // Add to response headers
        context.Response.Headers[CorrelationIdHeader] = correlationId;

        await _next(context);
    }
}

Extension Methods

Extensions/MiddlewareExtensions.cs C#
public static class MiddlewareExtensions
{
    public static IApplicationBuilder UseRequestTiming(
        this IApplicationBuilder app)
    {
        return app.UseMiddleware<RequestTimingMiddleware>();
    }

    public static IApplicationBuilder UseApiKey(
        this IApplicationBuilder app)
    {
        return app.UseMiddleware<ApiKeyMiddleware>();
    }

    public static IApplicationBuilder UseCorrelationId(
        this IApplicationBuilder app)
    {
        return app.UseMiddleware<CorrelationIdMiddleware>();
    }
}

Usage in Program.cs

Program.cs C#
var app = builder.Build();

// Custom middleware
app.UseCorrelationId();
app.UseRequestTiming();
app.UseApiKey();

// Built-in middleware
app.UseAuthentication();
app.UseAuthorization();

app.MapControllers();
app.Run();

Best Practices

  • Use extension methods for clean registration
  • Constructor: Only inject singleton services
  • InvokeAsync: Can inject scoped services
  • Always call next() unless short-circuiting intentionally
  • Use try/finally for cleanup (like timing)
  • Set response headers before calling next() if needed
  • Check response.HasStarted before modifying response
  • Use HttpContext.Items to pass data to later middleware
  • Keep middleware focused on one responsibility
  • Test middleware in isolation

Key Takeaways

  • Three approaches: Inline, Convention-based, Factory-based
  • Inline: Quick and simple with app.Use()
  • Convention-based: Most common, reusable classes
  • Factory-based: Implement IMiddleware for complex DI
  • DI rules: Constructor (singleton), InvokeAsync (scoped)
  • Extension methods make registration clean
  • Middleware is created once (singleton lifetime)
  • InvokeAsync is called per request
  • Always use try/finally for cleanup
  • Test middleware independently
🎯 Next Steps

You now know how to create custom middleware! In the next section, we'll explore Middleware Ordering—understanding why order matters, common ordering patterns, and how to avoid common mistakes.