Middleware en .NET Explicado: Cómo Funciona el Pipeline (y Errores Comunes)
Entiende cómo funciona el pipeline de middleware en ASP.NET Core por dentro, aprende a escribir middleware custom correctamente, y evita los errores que causan bugs silenciosos en producción.
Has llamado a app.UseAuthentication() y app.UseAuthorization() cientos de veces. Pero, ¿realmente sabes qué pasa cuando un request llega a tu pipeline? Y más importante, ¿sabes qué pasa cuando los pones en el orden equivocado?
Middleware es la columna vertebral de toda aplicación ASP.NET Core. Cada request pasa por él. Cada response pasa por él. Autenticación, logging, manejo de excepciones, CORS, routing: todo es middleware. Sin embargo, la mayoría de los desarrolladores lo tratan como boilerplate que copian de un tutorial sin entender el modelo de ejecución.
En este artículo vamos a desglosar cómo funciona realmente el pipeline de middleware, escribir middleware custom de la manera correcta, y corregir los errores más comunes que veo en code reviews.
Cómo Funciona el Pipeline
El pipeline de middleware en ASP.NET Core es una cadena de componentes, donde cada uno envuelve al siguiente. Pensalo como muñecas rusas: cada middleware tiene la oportunidad de procesar el request de ida, y luego procesar el response de vuelta.
Request → [Logging] → [Auth] → [Routing] → [Endpoint]
↓
Response ← [Logging] ← [Auth] ← [Routing] ← [Result]
Cada componente de middleware puede:
- Hacer algo antes de pasar el request al siguiente middleware
- Llamar a
next()para pasar el control al siguiente middleware - Hacer algo después de que el siguiente middleware haya retornado
- Cortocircuitar el pipeline al no llamar a
next()
Este es el middleware más simple posible:
app.Use(async (context, next) =>
{
// Antes: se ejecuta de IDA
Console.WriteLine("Before next middleware");
await next(context); // Pasar al siguiente middleware
// Después: se ejecuta de VUELTA
Console.WriteLine("After next middleware");
});
Esta ejecución en dos fases es lo que hace al middleware poderoso, y lo que hace que el orden sea crítico.
El Orden Importa: Un Ejemplo Real
Considerá este Program.cs:
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddAuthentication();
builder.Services.AddAuthorization();
builder.Services.AddControllers();
var app = builder.Build();
app.UseHttpsRedirection();
app.UseAuthentication();
app.UseAuthorization();
app.MapControllers();
app.Run();
Ahora mirá qué pasa si invertís autenticación y autorización:
// ❌ ORDEN INCORRECTO
app.UseAuthorization(); // Chequea auth... pero no existe identidad todavía!
app.UseAuthentication(); // Establece identidad... demasiado tarde
Autorización se ejecuta primero, no encuentra usuario autenticado, y rechaza el request. Autenticación nunca tiene la oportunidad de validar el token. El request falla con un 401, y vas a pasar horas debuggeando.
El orden recomendado es:
app.UseExceptionHandler(); // 1. Capturar errores de todo lo que viene abajo
app.UseHsts(); // 2. Headers de seguridad
app.UseHttpsRedirection(); // 3. Redirigir HTTP a HTTPS
app.UseStaticFiles(); // 4. Servir archivos estáticos (no necesitan auth)
app.UseRouting(); // 5. Determinar qué endpoint coincide
app.UseCors(); // 6. CORS (después de routing, antes de auth)
app.UseAuthentication(); // 7. ¿Quién sos?
app.UseAuthorization(); // 8. ¿Tenés permiso?
app.MapControllers(); // 9. Ejecutar endpoint
Esto no es arbitrario: cada paso depende del anterior. Routing necesita ejecutarse antes de CORS para que se pueda seleccionar la política correcta. Autenticación necesita ejecutarse antes de autorización para que haya una identidad contra la cual verificar.
Las Tres Formas de Escribir Middleware
1. Middleware Inline (Rápido y Sucio)
Usá app.Use() para middleware simple y de una sola vez:
app.Use(async (context, next) =>
{
var stopwatch = Stopwatch.StartNew();
context.Response.OnStarting(() =>
{
stopwatch.Stop();
context.Response.Headers.Append(
"X-Response-Time", $"{stopwatch.ElapsedMilliseconds}ms");
return Task.CompletedTask;
});
await next(context);
});
Bueno para prototipos. No es ideal para producción: no tiene inyección de dependencias ni testability.
Nota: Usamos
OnStarting()acá para agregar el header de response de forma segura. Modificar headers después de quenext()retorna puede lanzar una excepción si el response ya empezó a enviarse. Cubriremos esto en detalle en la sección de Errores Comunes.
2. Middleware por Convención (La Forma Estándar)
El patrón más común. Creás una clase con un método InvokeAsync:
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();
await _next(context);
stopwatch.Stop();
var elapsed = stopwatch.ElapsedMilliseconds;
_logger.LogInformation(
"{Method} {Path} completed in {ElapsedMs}ms with status {StatusCode}",
context.Request.Method,
context.Request.Path,
elapsed,
context.Response.StatusCode);
}
}
// Registrarlo
app.UseMiddleware<RequestTimingMiddleware>();
El middleware por convención se crea una sola vez al iniciar la aplicación. El constructor se ejecuta una vez, y la misma instancia maneja todos los requests. Esto significa:
- Las dependencias del constructor son singletons (incluso si están registradas como scoped o transient)
InvokeAsyncpuede inyectar servicios scoped como parámetros- No guardes estado específico del request en fields
public class RequestTimingMiddleware
{
private readonly RequestDelegate _next;
public RequestTimingMiddleware(RequestDelegate next)
{
_next = next;
}
// Servicios scoped inyectados acá, no en el constructor
public async Task InvokeAsync(HttpContext context, IOrderService orderService)
{
// orderService tiene scope correcto por request
await _next(context);
}
}
3. Middleware Factory-Based (IMiddleware)
Para middleware que necesita lifetime scoped o transient:
public class TransactionMiddleware : IMiddleware
{
private readonly AppDbContext _dbContext;
// Este constructor se ejecuta POR REQUEST (no una vez como el basado en convención)
public TransactionMiddleware(AppDbContext dbContext)
{
_dbContext = dbContext;
}
public async Task InvokeAsync(HttpContext context, RequestDelegate next)
{
using var transaction = await _dbContext.Database.BeginTransactionAsync();
try
{
await next(context);
if (context.Response.StatusCode < 400)
{
await transaction.CommitAsync();
}
else
{
await transaction.RollbackAsync();
}
}
catch
{
await transaction.RollbackAsync();
throw;
}
}
}
// Hay que registrarlo en DI
builder.Services.AddScoped<TransactionMiddleware>();
// Después usarlo
app.UseMiddleware<TransactionMiddleware>();
La diferencia clave: IMiddleware se resuelve desde DI por request, así que respeta el lifetime del servicio que registres. El middleware por convención es singleton sin importar nada.
Cortocircuitando el Pipeline
A veces querés detener el pipeline antes de tiempo, sin necesidad de llamar al siguiente middleware. Esto se llama cortocircuitar:
public class ApiKeyMiddleware
{
private readonly RequestDelegate _next;
private const string ApiKeyHeader = "X-API-Key";
public ApiKeyMiddleware(RequestDelegate next)
{
_next = next;
}
public async Task InvokeAsync(HttpContext context, IConfiguration config)
{
if (!context.Request.Headers.TryGetValue(ApiKeyHeader, out var extractedApiKey))
{
context.Response.StatusCode = StatusCodes.Status401Unauthorized;
await context.Response.WriteAsJsonAsync(new { error = "API key is missing" });
return; // Cortocircuitar: no llamar a _next
}
var validApiKey = config["ApiKey"];
if (!string.Equals(extractedApiKey, validApiKey, StringComparison.Ordinal))
{
context.Response.StatusCode = StatusCodes.Status403Forbidden;
await context.Response.WriteAsJsonAsync(new { error = "Invalid API key" });
return; // Cortocircuitar
}
await _next(context); // API key válida, continuar
}
}
Cuando no llamás a _next, ningún middleware downstream se ejecuta. El response fluye de vuelta a través del middleware upstream que ya se ejecutó.
Middleware Terminal con app.Run()
app.Run() es un caso especial: nunca llama a next() y siempre cortocircuita:
app.Run(async context =>
{
await context.Response.WriteAsync("Hello, World!");
});
// ⚠️ Nada después de app.Run() se ejecutará
app.UseAuthorization(); // Esto nunca se ejecuta!
Usá app.Run() solo como el último middleware en el pipeline, o para endpoints catch-all.
Middleware Condicional con app.UseWhen() y app.MapWhen()
Aplicá middleware solo a ciertos requests:
// UseWhen: aplica middleware condicionalmente, vuelve al pipeline principal después
app.UseWhen(
context => context.Request.Path.StartsWithSegments("/api"),
appBuilder =>
{
appBuilder.UseMiddleware<ApiKeyMiddleware>();
}
);
// MapWhen: bifurca el pipeline (terminal - no vuelve al principal)
app.MapWhen(
context => context.Request.Path.StartsWithSegments("/health"),
appBuilder =>
{
appBuilder.Run(async context =>
{
context.Response.StatusCode = 200;
await context.Response.WriteAsync("OK");
});
}
);
La diferencia importa: UseWhen vuelve al pipeline principal después de la rama (a menos que la rama contenga middleware terminal), mientras que MapWhen crea un fork completamente separado.
Extension Methods: Haciendo el Middleware Limpio
No obligues a tus consumidores a escribir app.UseMiddleware<TuMiddleware>(). Escribí un extension method:
public static class MiddlewareExtensions
{
public static IApplicationBuilder UseRequestTiming(this IApplicationBuilder builder)
{
return builder.UseMiddleware<RequestTimingMiddleware>();
}
public static IApplicationBuilder UseApiKey(this IApplicationBuilder builder)
{
return builder.UseMiddleware<ApiKeyMiddleware>();
}
}
// Uso limpio en Program.cs
app.UseRequestTiming();
app.UseApiKey();
Este es el patrón que usa todo el middleware built-in (UseAuthentication(), UseCors(), etc.).
Ejemplo Real: Middleware de Manejo Global de Excepciones
Este es uno de los patrones de middleware custom más útiles:
public class GlobalExceptionMiddleware
{
private readonly RequestDelegate _next;
private readonly ILogger<GlobalExceptionMiddleware> _logger;
public GlobalExceptionMiddleware(RequestDelegate next, ILogger<GlobalExceptionMiddleware> logger)
{
_next = next;
_logger = logger;
}
public async Task InvokeAsync(HttpContext context)
{
try
{
await _next(context);
}
catch (ValidationException ex)
{
_logger.LogWarning(ex, "Validation error on {Path}", context.Request.Path);
context.Response.StatusCode = StatusCodes.Status400BadRequest;
await context.Response.WriteAsJsonAsync(new
{
error = "Validation failed",
details = ex.Errors.Select(e => e.ErrorMessage)
});
}
catch (NotFoundException ex)
{
_logger.LogWarning("Resource not found: {Message}", ex.Message);
context.Response.StatusCode = StatusCodes.Status404NotFound;
await context.Response.WriteAsJsonAsync(new
{
error = ex.Message
});
}
catch (UnauthorizedAccessException)
{
context.Response.StatusCode = StatusCodes.Status403Forbidden;
await context.Response.WriteAsJsonAsync(new
{
error = "Access denied"
});
}
catch (Exception ex)
{
_logger.LogError(ex, "Unhandled exception on {Method} {Path}",
context.Request.Method, context.Request.Path);
context.Response.StatusCode = StatusCodes.Status500InternalServerError;
await context.Response.WriteAsJsonAsync(new
{
error = "An unexpected error occurred",
traceId = Activity.Current?.Id ?? context.TraceIdentifier
});
}
}
}
Registralo primero en el pipeline para que capture excepciones de todos los demás middleware:
app.UseMiddleware<GlobalExceptionMiddleware>(); // Primero!
app.UseHttpsRedirection();
app.UseAuthentication();
app.UseAuthorization();
app.MapControllers();
Ejemplo Real: Middleware de Logging de Request/Response
public class RequestResponseLoggingMiddleware
{
private readonly RequestDelegate _next;
private readonly ILogger<RequestResponseLoggingMiddleware> _logger;
public RequestResponseLoggingMiddleware(RequestDelegate next,
ILogger<RequestResponseLoggingMiddleware> logger)
{
_next = next;
_logger = logger;
}
public async Task InvokeAsync(HttpContext context)
{
// Log del request
_logger.LogInformation(
"HTTP {Method} {Path}{QueryString} from {IP}",
context.Request.Method,
context.Request.Path,
context.Request.QueryString,
context.Connection.RemoteIpAddress);
await _next(context);
// Log del response
_logger.LogInformation(
"HTTP {Method} {Path} responded {StatusCode} in {ContentLength} bytes",
context.Request.Method,
context.Request.Path,
context.Response.StatusCode,
context.Response.ContentLength);
}
}
Errores Comunes
Error 1: Modificar el Response Después de next()
// ❌ ROTO: el response puede que ya se haya enviado
public async Task InvokeAsync(HttpContext context)
{
await _next(context);
// Los headers puede que ya se hayan enviado al cliente!
context.Response.Headers.Append("X-Custom", "value"); // Puede lanzar excepción
context.Response.StatusCode = 200; // Demasiado tarde
}
Una vez que el body del response empieza a escribirse, no podés modificar los headers ni el status code. La solución es usar context.Response.OnStarting():
// ✅ CORRECTO: registrar callback antes de que el response empiece
public async Task InvokeAsync(HttpContext context)
{
context.Response.OnStarting(() =>
{
context.Response.Headers.Append("X-Custom", "value");
return Task.CompletedTask;
});
await _next(context);
}
Error 2: Olvidarse de Hacer Await a next()
// ❌ MAL: no se hace await a next
public async Task InvokeAsync(HttpContext context)
{
var stopwatch = Stopwatch.StartNew();
_next(context); // Falta el await!
stopwatch.Stop();
// Esto se ejecuta INMEDIATAMENTE, no después de que el pipeline termine
_logger.LogInformation("Request took {Ms}ms", stopwatch.ElapsedMilliseconds);
}
Sin await, tu código “después” se ejecuta antes de que el resto del pipeline termine. Vas a medir 0ms para cada request y preguntarte por qué tu logging está roto.
Error 3: Guardar Estado del Request en Fields del Middleware
// ❌ MAL: el middleware por convención es singleton!
public class BadMiddleware
{
private readonly RequestDelegate _next;
private string _currentUser; // Compartido entre TODOS los requests!
public BadMiddleware(RequestDelegate next)
{
_next = next;
}
public async Task InvokeAsync(HttpContext context)
{
_currentUser = context.User?.Identity?.Name; // Race condition!
await _next(context);
Console.WriteLine($"User: {_currentUser}"); // Usuario equivocado!
}
}
El middleware por convención es singleton. Los fields son compartidos entre todos los requests concurrentes. Usá variables locales:
// ✅ CORRECTO: variable local, con scope del request
public async Task InvokeAsync(HttpContext context)
{
var currentUser = context.User?.Identity?.Name; // Local, seguro
await _next(context);
Console.WriteLine($"User: {currentUser}");
}
Error 4: Inyectar Servicios Scoped en el Constructor
// ❌ MAL: DbContext es scoped, middleware es singleton
public class BadMiddleware
{
private readonly RequestDelegate _next;
private readonly AppDbContext _dbContext; // Captive dependency!
public BadMiddleware(RequestDelegate next, AppDbContext dbContext)
{
_next = next;
_dbContext = dbContext; // Misma instancia para TODOS los requests
}
}
Este es el problema de “captive dependency”. Un singleton mantiene una referencia a un servicio scoped, lo que significa que la misma instancia de DbContext se reutiliza para cada request, causando problemas de threading y datos obsoletos.
Solución: inyectá servicios scoped en InvokeAsync, no en el constructor:
// ✅ CORRECTO: inyectar en InvokeAsync
public class GoodMiddleware
{
private readonly RequestDelegate _next;
public GoodMiddleware(RequestDelegate next)
{
_next = next;
}
public async Task InvokeAsync(HttpContext context, AppDbContext dbContext)
{
// dbContext tiene scope correcto por request
await _next(context);
}
}
Error 5: Escribir en el Body del Response Y Llamar a next()
// ❌ MAL: escribe en el response, luego continúa el pipeline
public async Task InvokeAsync(HttpContext context)
{
if (someCondition)
{
await context.Response.WriteAsync("Error!");
// Falta el return! Cae al siguiente middleware
}
await _next(context); // El pipeline intenta escribir en un response ya iniciado
}
Si escribís en el body del response, tenés que hacer return para cortocircuitar. Si no, el siguiente middleware puede intentar escribir de nuevo, causando una excepción Response has already started.
// ✅ CORRECTO: return después de escribir
public async Task InvokeAsync(HttpContext context)
{
if (someCondition)
{
await context.Response.WriteAsync("Error!");
return; // Cortocircuitar
}
await _next(context);
}
Error 6: Llamar a next() Múltiples Veces
// ❌ MAL: llamando a next dos veces
public async Task InvokeAsync(HttpContext context)
{
await _next(context);
if (context.Response.StatusCode == 404)
{
context.Response.Clear();
await _next(context); // Doble ejecución!
}
}
Llamar a _next() dos veces significa que todo el pipeline downstream se ejecuta dos veces. Esto puede causar escrituras duplicadas en la base de datos, doble logging, y streams de response corruptos. El pipeline está diseñado para una sola pasada.
Middleware vs Filtros: Cuándo Usar Cada Uno
ASP.NET Core también tiene action filters, que se ejecutan dentro del pipeline de MVC/routing. Acá va cuándo usar cada uno:
Usá middleware cuando:
- Necesitás ejecutar lógica para cada request (incluyendo archivos estáticos)
- Necesitás ejecutar antes del routing
- Estás manejando concerns transversales (logging, timing, CORS, headers de seguridad)
- Querés cortocircuitar antes de que MVC siquiera se ejecute
Usá filtros cuando:
- Necesitás acceso a
ActionContexto resultados de model binding - La lógica solo aplica a acciones de controllers
- Necesitás ejecutar antes/después de métodos de acción específicos
- Necesitás acceso a los parámetros o resultado de la acción
Request → [Middleware Pipeline] → [Routing] → [Filters] → [Action Method]
Middleware envuelve todo el pipeline. Los filtros envuelven solo la ejecución de la acción del controller.
Conclusión
Middleware es una de esas cosas que es fácil de usar y fácil de usar mal. El modelo de pipeline es simple en concepto pero tiene comportamientos sutiles que agarran desprevenidos a los desarrolladores.
Puntos clave:
- El orden importa: autenticación antes de autorización, manejo de excepciones primero, archivos estáticos antes de routing
- El middleware por convención es singleton: no guardes estado de request en fields, no inyectes servicios scoped en el constructor
- Siempre hacé await a next(): olvidarse de esto rompe el pipeline silenciosamente
- Cortocircuitá correctamente: si escribís en el response, hacé return inmediatamente
- Usá OnStarting() para headers de response: no modifiques headers después de que
next()retorne - Los extension methods hacen el middleware limpio: seguí el patrón
UseXxx()
Entendé el pipeline, respetá el orden, y tu middleware va a funcionar exactamente como esperás.