Table of Contents
TL;DR:
ASP.NET Core middleware is powerful, but easy to misuse in SaaS apps. This post covers:
- Lifetime Mismatches, like using
DbContextin singletons. - Misplaced Logic, putting business rules or auth in middleware.
- Pipeline Breakers, forgetting
next()or misresolving tenant context.
Each mistake is common, dangerous, and avoidable with simple patterns.
This post is a follow-up to:
Why Custom Middleware Should Be Your First SaaS Extension Point, Not DI
If you’re not convinced middleware belongs in your SaaS architecture, read that first.
Introduction
Middleware is one of ASP.NET Core’s most powerful features, but also one of its most dangerous when misused. In SaaS applications, bad middleware design becomes a shared pain across all tenants, amplifying problems throughout your customer base. I’ve seen middleware mistakes bring down production systems and create mysterious bugs that only surface under specific tenant configurations.
Before we dive into specific middleware mistakes, here’s how they tend to fall into 3 major trap zones in ASP.NET Core SaaS development:
Lifetime & Scope Issues
| Anti-Pattern | Problem | Fix |
|---|---|---|
| Hitting Databases from Singleton Middleware | Singleton middleware using scoped DbContext leads to data corruption | Use IServiceScopeFactory or implement IMiddleware |
| Shared Fields in Middleware | Instance fields become shared across requests, causing data leakage | Use local variables or store in HttpContext.Items |
Responsibility Confusion
| Anti-Pattern | Problem | Fix |
|---|---|---|
| Putting Business Logic in Middleware | Middleware making business decisions creates inflexible, hard-to-test code | Use middleware only for context setting; move business logic to services |
| Over-Serializing or Writing Full Responses | Building complete responses in middleware violates single responsibility | Set status codes and context only; delegate response formatting |
| Excessive Logging | Verbose logging in middleware creates noise that drowns important signals | Log selectively for sensitive operations or unusual behavior |
Missing Safeguards
| Anti-Pattern | Problem | Fix |
|---|---|---|
Skipping next() or Swallowing Exceptions | Forgetting next() or hiding exceptions breaks the request pipeline | Always call next() or explicitly return; properly log and rethrow exceptions |
| Tenant Context Without Fallbacks | Assuming perfect headers leads to crashes on malformed requests | Implement multi-source fallbacks and graceful error handling |
Here’s what not to do when building middleware for your SaaS application. These mistakes impact tenant isolation, performance, and the maintainability of your ASP.NET Core SaaS platform.
Anti-Pattern 1: Putting Business Logic in Middleware
The biggest mistake I see is cramming business decisions into middleware. Middleware should shape requests and set context, not make business decisions.
✖ Wrong Way / ✓ Right Way
// ✖ WRONG: Business decisions in middleware
public class BadMiddleware {
public async Task InvokeAsync(HttpContext ctx) {
var tenant = await GetTenantFromDb(ctx.Headers["X-Tenant-ID"]);
// ✖ Business logic doesn't belong here
if (tenant.BillingStatus == "Overdue") {
ctx.Response.StatusCode = 402;
return;
}
}
}
// ✓ RIGHT: Context setting only
public class GoodMiddleware {
public async Task InvokeAsync(HttpContext ctx) {
var tenantId = ctx.Headers["X-Tenant-ID"].FirstOrDefault();
ctx.Items["TenantId"] = tenantId; // Just store identity
await _next(ctx);
}
}
// ✓ Business logic belongs in services
public class TenantService {
public bool CanAccessFeature(string tenantId, string feature) {
// Business rules belong here
}
}
Real-world impact: I once debugged a production issue where billing logic in middleware prevented a customer’s emergency data export. The middleware blocked the request before it reached our controllers. Moving that logic to a service made it testable with proper error handling.
Anti-Pattern 2: Hitting Databases from Singleton Middleware
Middleware instances are singletons by default, but database contexts are scoped to requests. This mismatch creates subtle bugs and data corruption issues.
✖ Wrong Way / ✓ Right Way
// ✖ WRONG: Direct DbContext injection into singleton
public class BadMiddleware {
private readonly AppDbContext _db; // ✖ Lifetime mismatch!
public BadMiddleware(AppDbContext db) {
_db = db; // This will cause problems!
}
public async Task InvokeAsync(HttpContext ctx) {
// ✖ Singleton using scoped service = data corruption
var tenant = await _db.Tenants.FirstOrDefaultAsync(...);
}
}
// ✓ RIGHT: Option 1 - Use IServiceScopeFactory
public class GoodMiddleware1 {
private readonly IServiceScopeFactory _factory;
public async Task InvokeAsync(HttpContext ctx) {
using var scope = _factory.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
// Now safely use the scoped DbContext
}
}
// ✓ RIGHT: Option 2 - Implement IMiddleware interface
public class GoodMiddleware2 : IMiddleware { // Creates per-request instances
private readonly AppDbContext _db; // Safe with IMiddleware
public async Task InvokeAsync(HttpContext ctx, RequestDelegate next) {
// DbContext safely scoped to this request
}
}
Heads-up:
IMiddlewareis best when you need database access as it creates per-request instances, allowing direct injection of scoped services likeDbContext.
Anti-Pattern 3: Over-Serializing or Writing Full Responses
Middleware that writes complete responses violates the single responsibility principle. Middleware should shape requests, not build responses.
✖ Wrong Way / ✓ Right Way
// ✖ WRONG: Building full responses in middleware
public class BadMiddleware {
public async Task InvokeAsync(HttpContext ctx) {
try {
await _next(ctx);
}
catch (TenantNotFoundException ex) {
ctx.Response.ContentType = "application/json";
await ctx.Response.WriteAsync(JsonSerializer.Serialize(new {
error = "tenant_not_found",
supportEmail = "support@yourapp.com",
docs = "https://docs.example.com/errors"
}));
}
}
}
// ✓ RIGHT: Set context and delegate formatting
public class GoodMiddleware {
public async Task InvokeAsync(HttpContext ctx) {
try {
await _next(ctx);
}
catch (TenantNotFoundException ex) {
ctx.Response.StatusCode = 404;
ctx.Items["ErrorType"] = "TenantNotFound";
throw; // Forward to a proper formatter
}
}
}
Debug Note: I spent hours debugging serialization issues because middleware was setting
ContentTypeheaders that conflicted with controller actions. Keep middleware focused on request flow, not response content.
Anti-Pattern 4: Skipping next() or Swallowing Exceptions
One of the most dangerous middleware pitfalls is forgetting to call next() or improperly handling exceptions. These mistakes break the request pipeline and create mysterious hangs.
✖ Wrong Way / ✓ Right Way
// ✖ Short-circuit path is missing return
if (ctx.Request.Path.StartsWithSegments("/health")) {
ctx.Response.StatusCode = 200;
await ctx.Response.WriteAsync("OK");
// next middleware still runs , unintended
}
// ✖ Swallowing exceptions , hides real errors
try {
await _next(ctx);
}
catch (Exception ex) {
ctx.Response.StatusCode = 500;
await ctx.Response.WriteAsync("Error");
// No logging, no rethrow , diagnostic black hole
}
// ✓ Explicit short-circuit for health checks
if (ctx.Request.Path.StartsWithSegments("/health")) {
ctx.Response.StatusCode = 200;
await ctx.Response.WriteAsync("OK");
return; // ensures pipeline stops here
}
// ✓ Log and propagate exceptions correctly
try {
await _next(ctx);
}
catch (Exception ex) {
_logger.LogError(ex, "Pipeline error");
throw;
}
Always call await next(context) unless intentionally short-circuiting. Forgetting return after writing a response or swallowing exceptions leads to hard-to-debug issues.
Anti-Pattern 5: Tenant Context Without Fallbacks
SaaS applications need robust tenant resolution, but many developers assume the happy path. Missing tenant headers or malformed requests must be handled gracefully.
✖ Wrong Way / ✓ Right Way
// ✖ Assumes perfect header
var tenantId = ctx.Request.Headers["X-Tenant-ID"].First();
var tenant = await GetTenant(tenantId); // May throw
ctx.Items["Tenant"] = tenant; // Risky if null
// ✓ Multi-source fallback + error handling
var tenantId = ctx.Request.Headers["X-Tenant-ID"].FirstOrDefault()
?? ExtractFromSubdomain(ctx.Request.Host.Host)
?? ctx.Request.Query["tenant"].FirstOrDefault();
if (string.IsNullOrEmpty(tenantId)) {
if (_options.RequireTenant) {
ctx.Response.StatusCode = 400; return;
}
tenantId = _options.DefaultTenantId;
}
var tenant = await _repo.GetByIdAsync(tenantId);
if (tenant == null) {
ctx.Response.StatusCode = 404; return;
}
ctx.Items["Tenant"] = tenant;
await _next(ctx);
Pro Tip:
Use correlation IDs and structured logging to trace tenant issues across your request pipeline. When a customer reports a problem, you can search logs by correlation ID to see exactly what happened in their request.
Anti-Pattern 6: Shared Fields in Middleware
Middleware is registered as a singleton by default. This means it’s constructed once and reused for every request. Instance fields become shared across all requests, leading to data leakage.
✖ Wrong Way / ✓ Right Way
// ✖ Shared field , breaks under concurrency
private Tenant _tenant;
public async Task InvokeAsync(HttpContext ctx) {
_tenant = await GetTenantAsync(ctx.Request.Headers["X-Tenant-ID"]);
ctx.Items["Tenant"] = _tenant;
await _next(ctx);
}
// ✓ Local variable , safe per-request scope
public async Task InvokeAsync(HttpContext ctx) {
var tenant = await GetTenantAsync(ctx.Request.Headers["X-Tenant-ID"]);
ctx.Items["Tenant"] = tenant;
await _next(ctx);
}
Tip: If you find yourself reaching for a class-level field in middleware, pause. Ask yourself: “Is this data truly needed across requests?” Most of the time, it belongs in a local variable or in
HttpContext.Items.
Anti-Pattern 7: Excessive Logging
Logging inside middleware is powerful but easy to abuse. When logging every request at Information level, you create massive log noise that drowns important signals.
✖ Wrong Way / ✓ Right Way
// ✖ Logs every request with full headers , too verbose
_logger.LogInformation("Request: {Path}, {Headers}",
ctx.Request.Path, ctx.Request.Headers);
// ✓ Focused logging for sensitive paths
if (ctx.Request.Path.StartsWithSegments("/api/admin")) {
_logger.LogInformation("Admin access: {Path}", ctx.Request.Path);
}
await _next(ctx);
Pro Tip: Let downstream components handle detailed logs. Middleware logging should be surgical, only for critical context or unusual behavior.
Diagnostic Pattern: Catching Middleware Issues
Here’s a simple middleware that helps catch these anti-patterns in production:
// Detect middleware issues in production
public class DiagnosticsMiddleware {
public async Task InvokeAsync(HttpContext ctx) {
var stopwatch = Stopwatch.StartNew();
var responseStarted = ctx.Response.HasStarted;
try {
await _next(ctx);
// Flag slow middleware chains
if (stopwatch.ElapsedMilliseconds > 500) {
_logger.LogWarning("Slow middleware: {Duration}ms",
stopwatch.ElapsedMilliseconds);
}
// Detect improper response writing
if (ctx.Response.HasStarted != responseStarted &&
!ctx.Items.ContainsKey("IntentionalResponse")) {
_logger.LogWarning("Response without flow control");
}
}
catch (Exception ex) {
_logger.LogError(ex, "Pipeline exception");
throw;
}
}
}
Clean Middleware Principles
| Principle | Implementation | Benefit |
|---|---|---|
| Single Responsibility | One concern per middleware | Easy to test and debug |
| Context Setting | Enrich HttpContext.Items | Downstream components get context |
| Proper Flow Control | Always call next() or return | Predictable pipeline |
| Defensive Programming | Handle missing/invalid input | Robust multi-tenant apps |
Key Takeaways
Middleware makes or breaks your SaaS request pipeline. These anti-patterns come from real production issues I’ve debugged and architectural mistakes I’ve made.
Clean middleware is short, focused, and defers complex decisions downstream. Middleware should shape requests and set context, everything else belongs in services, controllers, or filters. When in doubt, push complexity downstream where it’s easier to test and maintain.
Remember that middleware runs for every request across all your tenants. A small issue becomes amplified across your entire customer base.
Heads-up: I’ve seen teams try to make middleware do everything, creating monolithic components that are impossible to test. Keep each middleware focused on one specific concern.