TL;DR:

In multi-tenant SaaS apps, middleware runs early - before routing, model binding, or dependency injection. It gives you unaltered access to the request.

Use middleware to shape the request: set tenant headers, apply CORS rules, log traces, or reject invalid calls. Keep it focused on context, not behavior.

Avoid using middleware for:

  • Long-running DB calls (unless you’re inside a scoped context)
  • Business rules, response formatting, or serialization
  • Anything that belongs in services or controllers

Middleware gives you early control. Dependency injection wires behavior.

For SaaS extension points, start with middleware. Use DI to delegate logic.

When I first built our multi-tenant architecture, I reached for DI out of habit. Constructor injection felt clean, testable, and followed all the SOLID principles I’d learned. It worked, until we needed tenant-specific behavior before DI kicked in. That’s when I learned middleware isn’t just another tool in the ASP.NET Core pipeline - it’s the earliest opportunity to influence the request.

We use DI because it’s familiar, not because it’s always the right tool.

Middleware Runs First, That’s the Superpower

The ASP.NET Core request pipeline is beautifully simple: middleware components execute in order, each one getting full access to the HttpContext before passing control to the next component.

This means middleware can inspect headers, cookies, paths, and query parameters while everything is still mutable.


graph TD
    A[HTTP Request] --> B[Custom Middleware]
    B --> C[Authentication]
    C --> D[Authorization] 
    D --> E[DI Container Resolution]
    E --> F[Controller/Action]
    F --> G[HTTP Response]
    
    style B fill:#e1f5fe
    style E fill:#fff3e0

    

Middleware Execution Order in ASP.NET Core

SaaS Middleware vs DI: Who Controls the Context?

Here’s the critical timing difference that most developers miss:


gantt
    title Request Lifecycle: Middleware vs DI Control Points
    dateFormat X
    axisFormat %s
    
    section Pipeline
    HTTP Request       :0, 1
    Custom Middleware  :1, 3
    Authentication     :3, 4
    Authorization      :4, 5
    DI Resolution      :5, 7
    Controller Action  :7, 9
    
    section Context Control
    Full Context Access    :crit, 1, 3
    Limited Context Access :2, 5
    No Context Control     :5, 9

    

Request Lifecycle: Middleware vs DI Control Points

Middleware gives you the only place where everything is still possible. Once the request moves past your custom middleware, you’re working within constraints set by upstream components.

Here’s what middleware sees that DI can’t:

  • Raw headers before authentication transforms them
  • Original request paths before routing normalizes them
  • Cookie values before security policies restrict access
  • Request timing before other middleware adds latency

Why Middleware is the Right Place for Tenant Resolution

Let’s build a practical multi-tenant middleware ASP.NET Core example. In SaaS applications, tenant context drives everything, database connections, feature flags, billing limits, and custom configurations.

public class TenantResolutionMiddleware
{
    private readonly RequestDelegate _next;
    private readonly ILogger<TenantResolutionMiddleware> _logger;

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

    public async Task InvokeAsync(HttpContext context)
    {
        var tenantId = ResolveTenantId(context);
        
        if (tenantId != null)
        {
            // Set tenant context before any services resolve
            context.Items["TenantId"] = tenantId;
            context.Items["TenantContext"] = await BuildTenantContext(tenantId);
            
            _logger.LogInformation("Resolved tenant {TenantId} for request {Path}", 
                tenantId, context.Request.Path);
        }

        await _next(context);
    }

    private string? ResolveTenantId(HttpContext context)
    {
        // Check subdomain first: tenant1.yourapp.com
        var host = context.Request.Host.Host;
        if (host.Contains('.'))
        {
            var subdomain = host.Split('.')[0];
            if (!string.Equals(subdomain, "www", StringComparison.OrdinalIgnoreCase))
                return subdomain;
        }

        // Check custom header: X-Tenant-ID
        if (context.Request.Headers.TryGetValue("X-Tenant-ID", out var headerValue))
            return headerValue.FirstOrDefault();

        // Check query parameter: ?tenant=tenant1
        if (context.Request.Query.TryGetValue("tenant", out var queryValue))
            return queryValue.FirstOrDefault();

        return null;
    }

    private async Task<TenantContext> BuildTenantContext(string tenantId)
    {
        // Build tenant-specific context early in the pipeline
        return new TenantContext
        {
            TenantId = tenantId,
            DatabaseConnection = $"Server=db-{tenantId}.internal;Database=App_{tenantId}",
            FeatureFlags = await LoadFeatureFlags(tenantId),
            BillingTier = await GetBillingTier(tenantId)
        };
    }
}

Register it early in your pipeline:

// Program.cs
app.UseMiddleware<TenantResolutionMiddleware>();
app.UseAuthentication();
app.UseAuthorization();

Injecting ITenantService? By that point, context is already set. By the time DI resolves your services, the request context is locked in.

Don’t Inject Prematurely: DI’s Blind Spot

Constructor injection works brilliantly for static dependencies, but SaaS extension points require dynamic context that changes per request. Consider this common anti-pattern:

// ❌ This breaks with dynamic tenants
public class OrderService
{
    private readonly ITenantDbContext _dbContext;
    
    public OrderService(ITenantDbContext dbContext)
    {
        _dbContext = dbContext; // Which tenant? Constructor doesn't know yet!
    }
}

The problem: constructors execute once per service lifetime, but tenant context changes every request. DI is static, but tenants are dynamic.

Compare that to middleware-driven context:

// ✅ Middleware sets context, services consume it
public class OrderService
{
    private readonly IHttpContextAccessor _httpContextAccessor;
    
    public OrderService(IHttpContextAccessor httpContextAccessor)
    {
        _httpContextAccessor = httpContextAccessor;
    }
    
    private TenantContext GetTenantContext()
    {
        return _httpContextAccessor.HttpContext?.Items["TenantContext"] as TenantContext
            ?? throw new InvalidOperationException("Tenant context not resolved");
    }
}

How Middleware Solved a Critical Production Issue

In a high-stakes production issue, I encountered the limits of our DI-based model. An enterprise customer needed their API requests to bypass our rate limiting during a critical data migration. The request came in at 2 AM.

Our DI-based rate limiting service was buried deep in the controller pipeline. To add the bypass logic, I would have needed to:

  1. Modify the IRateLimitService interface
  2. Update all implementations
  3. Change registration in 3 different projects
  4. Test the entire request flow

Instead, I added 10 lines to our existing middleware:

public async Task InvokeAsync(HttpContext context)
{
    // Emergency bypass for customer migration
    if (context.Request.Headers.TryGetValue("X-Migration-Token", out var token) &&
        IsValidMigrationToken(token))
    {
        context.Items["BypassRateLimit"] = true;
        _logger.LogWarning("Rate limiting bypassed for migration token");
    }
    
    await _next(context);
}

One middleware fix avoided refactoring 14 services. The bypass was live in production within 20 minutes.

Making Middleware Your First-Class Extension Point

Think of middleware like a command center for every request. Here’s how to build middleware that scales:

public class ExtensibleSaasMiddleware
{
    private readonly RequestDelegate _next;
    private readonly IServiceProvider _serviceProvider;
    private readonly List<IRequestProcessor> _processors;

    public ExtensibleSaasMiddleware(
        RequestDelegate next, 
        IServiceProvider serviceProvider,
        IEnumerable<IRequestProcessor> processors)
    {
        _next = next;
        _serviceProvider = serviceProvider;
        _processors = processors.OrderBy(p => p.Order).ToList();
    }

    public async Task InvokeAsync(HttpContext context)
    {
        using var scope = _serviceProvider.CreateScope();
        var requestContext = new RequestContext(context, scope.ServiceProvider);

        // Execute all registered processors
        foreach (var processor in _processors)
        {
            await processor.ProcessAsync(requestContext);
        }

        await _next(context);
    }
}

public interface IRequestProcessor
{
    int Order { get; }
    Task ProcessAsync(RequestContext context);
}

// Example: Feature flag processor
public class FeatureFlagProcessor : IRequestProcessor
{
    public int Order => 100;
    
    public async Task ProcessAsync(RequestContext context)
    {
        var tenantId = context.HttpContext.Items["TenantId"]?.ToString();
        if (tenantId != null)
        {
            var flags = await LoadFeatureFlags(tenantId);
            context.HttpContext.Items["FeatureFlags"] = flags;
        }
    }
}

Key principles for extensible middleware:

PrincipleImplementationBenefit
Order mattersUse Order properties for processorsPredictable execution sequence
Scoped servicesCreate scope per requestProper service lifetime management
Fail fastValidate context earlyClear error messages
Stateless designAvoid instance variablesThread-safe by default

Pro Tip:
Middleware processes requests before any routing or binding decisions, keeping all options open.

DI Still Has a Place, But Not at the Front Door

Middleware sets the scene, DI delivers the actors. Once your middleware establishes tenant context, feature flags, and request metadata, DI excels at injecting the right services:

// Middleware sets context
app.UseMiddleware<TenantResolutionMiddleware>();
app.UseMiddleware<FeatureFlagMiddleware>();

// DI provides tenant-aware services
builder.Services.AddScoped<ITenantDbContext>(provider =>
{
    var httpContext = provider.GetRequiredService<IHttpContextAccessor>().HttpContext;
    var tenantContext = httpContext?.Items["TenantContext"] as TenantContext;
    return new TenantDbContext(tenantContext?.DatabaseConnection);
});

Use DI for:

  • Service implementation selection based on middleware context
  • Tenant-specific configuration injection
  • Scoped services that need request-level data

Avoid DI for:

  • Request inspection and routing decisions
  • Cross-cutting concerns that affect the entire pipeline
  • Context establishment that other services depend on

Common Pitfalls and Debugging

Middleware order problems are the #1 source of bugs. A debugging middleware that reduced my debugging time significantly:

public class PipelineDebugMiddleware
{
    private readonly RequestDelegate _next;
    private readonly ILogger<PipelineDebugMiddleware> _logger;

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

    public async Task InvokeAsync(HttpContext context)
    {
        _logger.LogInformation("Pipeline Debug - Request: {Method} {Path}", 
            context.Request.Method, context.Request.Path);
            
        _logger.LogInformation("Available Context Items: {Items}", 
            string.Join(", ", context.Items.Keys.Cast<string>()));

        await _next(context);
        
        _logger.LogInformation("Pipeline Debug - Response: {StatusCode}", 
            context.Response.StatusCode);
    }
}

Debug Note:
Debugging context-related bugs? Middleware gives you full access to headers, cookies, and path before the DI container commits.

Common mistakes to avoid:

  1. Heavy computation in middleware - Keep it fast, delegate to scoped services
  2. Stateful middleware - Store state in HttpContext.Items, not instance variables
  3. Missing error handling - Always wrap await _next(context) in try/catch
  4. Wrong registration order - Register context-setting middleware before consumers

Mental Model: Middleware as Your Request Bouncer

Think of middleware like a bouncer at an exclusive club. The bouncer:

  • Checks IDs (authentication/authorization)
  • Assigns VIP status (feature flags, billing tiers)
  • Sets table preferences (tenant context, localization)
  • Handles special requests (rate limiting, custom headers)

Once you’re inside the club (past middleware), the waitstaff (DI services) know exactly how to serve you based on what the bouncer already determined.

Start with Middleware for Extension Points

Switching to middleware didn’t just fix bugs, it provided better request pipeline visibility. Now, we use a middleware-first strategy for extensibility. This approach has reduced refactoring time significantly and given us the flexibility to handle edge cases that would break traditional DI patterns.

Need SaaS flexibility? Own the request pipeline first. This helps avoid future bugs and rework when the inevitable “special case” customer request comes in at 2 AM.

For SaaS applications, middleware provides:

  • Early context resolution before services commit to specific implementations
  • Request metadata access for debugging and monitoring
  • Extension points that don’t require deep architectural changes
  • Performance benefits from processing context once per request

Personal Experience:
When I first built our multi-tenant architecture, I reached for DI out of habit. It worked, until tenant-specific behavior was needed before DI kicked in. Switching to middleware gave us visibility before anything else touched the request.

About the Author

Abhinaw Kumar is a software engineer who builds real-world systems: from resilient ASP.NET Core backends to clean, maintainable Angular frontends. With over 11+ years in production development, he shares what actually works when you're shipping software that has to last.

Read more on the About page or connect on LinkedIn.

Further Reading

Related Posts