O middleware pipeline é o coração do ASP.NET Core: toda requisição HTTP passa por uma sequência de componentes antes de chegar ao seu controller ou endpoint. Entender como funciona — e como criar middlewares customizados — é essencial para implementar cross-cutting concerns de forma limpa e reutilizável.
Como o pipeline funciona
O pipeline é uma cadeia de delegates. Cada middleware recebe a requisição, pode processá-la, e decide se passa para o próximo componente ou encerra o fluxo:
Request ──▶ [Auth] ──▶ [RateLimit] ──▶ [Logging] ──▶ [Routing] ──▶ [Endpoint]
◀────────────────────────────────────────────────────── Response
// Program.cs — ordem IMPORTA: os middlewares executam na ordem de registro
var app = builder.Build();
// A ordem correta para APIs em produção:
app.UseExceptionHandler("/error"); // 1. Captura exceções de tudo abaixo
app.UseHttpsRedirection(); // 2. Redireciona HTTP → HTTPS
app.UseStaticFiles(); // 3. Serve arquivos estáticos (sem auth)
app.UseRouting(); // 4. Resolve a rota
app.UseAuthentication(); // 5. Quem é você?
app.UseAuthorization(); // 6. Você pode fazer isso?
app.UseRateLimiter(); // 7. Dentro dos limites?
// Middlewares customizados entram aqui
app.UseRequestContextEnricher(); // 8. Enriquece contexto (tenant, correlation ID)
app.MapControllers(); // 9. Executa o handler
Use, Run e Map: os três verbos do pipeline
// Use() — passa para o próximo middleware
app.Use(async (context, next) =>
{
// Antes do próximo middleware
Console.WriteLine($">> {context.Request.Method} {context.Request.Path}");
await next(context); // Chama o próximo na cadeia
// Depois do próximo middleware (resposta voltando)
Console.WriteLine($"<< {context.Response.StatusCode}");
});
// Run() — terminal: não chama o próximo (short-circuit)
app.Run(async context =>
{
await context.Response.WriteAsync("Fim da linha — nenhum middleware abaixo executa");
});
// Map() — ramificação condicional por path
app.Map("/health", healthApp =>
{
healthApp.Run(async context =>
await context.Response.WriteAsync("Healthy"));
});
// MapWhen() — ramificação condicional por predicado
app.MapWhen(
context => context.Request.Headers.ContainsKey("X-Internal-Request"),
internalApp =>
{
internalApp.UseMiddleware<InternalRequestMiddleware>();
});
Middleware class-based (convention-based): o padrão correto
Para middlewares reutilizáveis, use a abordagem baseada em classe:
// Middleware de correlation ID — injeta/lê um ID único por requisição
public class CorrelationIdMiddleware
{
private const string CorrelationIdHeader = "X-Correlation-ID";
private readonly RequestDelegate _next;
private readonly ILogger<CorrelationIdMiddleware> _logger;
// RequestDelegate e serviços Singleton são injetados no construtor
public CorrelationIdMiddleware(
RequestDelegate next,
ILogger<CorrelationIdMiddleware> logger)
{
_next = next;
_logger = logger;
}
// InvokeAsync recebe o contexto e serviços Scoped/Transient
public async Task InvokeAsync(
HttpContext context,
ICorrelationIdService correlationIdService) // Injetado aqui — não no construtor!
{
// Lê do header ou gera um novo
var correlationId = context.Request.Headers[CorrelationIdHeader].FirstOrDefault()
?? Guid.NewGuid().ToString("N");
// Armazena no contexto para uso downstream
context.Items["CorrelationId"] = correlationId;
correlationIdService.SetCurrentId(correlationId);
// Adiciona no header de resposta para rastreabilidade
context.Response.Headers[CorrelationIdHeader] = correlationId;
// Enriquece o log scope
using var logScope = _logger.BeginScope(new Dictionary<string, object>
{
["CorrelationId"] = correlationId
});
await _next(context);
}
}
// Extensão para registro limpo no Program.cs
public static class CorrelationIdMiddlewareExtensions
{
public static IApplicationBuilder UseCorrelationId(
this IApplicationBuilder app)
=> app.UseMiddleware<CorrelationIdMiddleware>();
}
// Program.cs
app.UseCorrelationId();
Factory-based middleware: para middlewares que precisam de DI Scoped
// IMiddleware — abordagem factory-based
// Diferença: a instância é criada por requisição (suporta Scoped no construtor)
public class TenantResolutionMiddleware : IMiddleware
{
private readonly ITenantRepository _tenantRepository; // Scoped — OK aqui!
private readonly ITenantContext _tenantContext;
public TenantResolutionMiddleware(
ITenantRepository tenantRepository,
ITenantContext tenantContext)
{
_tenantRepository = tenantRepository;
_tenantContext = tenantContext;
}
public async Task InvokeAsync(HttpContext context, RequestDelegate next)
{
// Resolve tenant a partir do subdomínio
var host = context.Request.Host.Host; // ex: "acme.neryx.com.br"
var subdomain = host.Split('.').FirstOrDefault();
if (subdomain is not null and not "www" and not "app")
{
var tenant = await _tenantRepository.GetBySubdomainAsync(subdomain);
if (tenant is null)
{
context.Response.StatusCode = 404;
await context.Response.WriteAsJsonAsync(new
{
Error = "Tenant não encontrado",
Subdomain = subdomain
});
return; // Short-circuit — não chama o próximo
}
_tenantContext.SetCurrent(tenant);
}
await next(context);
}
}
// Factory-based EXIGE registro explícito no DI
builder.Services.AddScoped<TenantResolutionMiddleware>();
// E uso via UseMiddleware
app.UseMiddleware<TenantResolutionMiddleware>();
Middleware de logging de requisições estruturado
public class RequestLoggingMiddleware
{
private readonly RequestDelegate _next;
private readonly ILogger<RequestLoggingMiddleware> _logger;
// Paths a não logar (health checks, métricas)
private static readonly HashSet<string> _ignoredPaths = new(StringComparer.OrdinalIgnoreCase)
{
"/health", "/health/live", "/health/ready", "/metrics"
};
public RequestLoggingMiddleware(
RequestDelegate next,
ILogger<RequestLoggingMiddleware> logger)
{
_next = next;
_logger = logger;
}
public async Task InvokeAsync(HttpContext context)
{
if (_ignoredPaths.Contains(context.Request.Path))
{
await _next(context);
return;
}
var sw = Stopwatch.StartNew();
try
{
await _next(context);
sw.Stop();
var level = context.Response.StatusCode >= 500
? LogLevel.Error
: context.Response.StatusCode >= 400
? LogLevel.Warning
: LogLevel.Information;
_logger.Log(level,
"HTTP {Method} {Path} responded {StatusCode} in {ElapsedMs}ms",
context.Request.Method,
context.Request.Path,
context.Response.StatusCode,
sw.ElapsedMilliseconds);
}
catch (Exception ex)
{
sw.Stop();
_logger.LogError(ex,
"HTTP {Method} {Path} threw exception after {ElapsedMs}ms",
context.Request.Method,
context.Request.Path,
sw.ElapsedMilliseconds);
throw;
}
}
}
Middleware de tratamento global de exceções
// .NET 8+: IExceptionHandler (preferível ao middleware manual)
public class GlobalExceptionHandler : IExceptionHandler
{
private readonly ILogger<GlobalExceptionHandler> _logger;
public GlobalExceptionHandler(ILogger<GlobalExceptionHandler> logger)
=> _logger = logger;
public async ValueTask<bool> TryHandleAsync(
HttpContext context,
Exception exception,
CancellationToken cancellationToken)
{
var (statusCode, title) = exception switch
{
ValidationException => (StatusCodes.Status422UnprocessableEntity, "Erro de validação"),
NotFoundException => (StatusCodes.Status404NotFound, "Recurso não encontrado"),
UnauthorizedException => (StatusCodes.Status401Unauthorized, "Não autenticado"),
ForbiddenException => (StatusCodes.Status403Forbidden, "Acesso negado"),
ConflictException => (StatusCodes.Status409Conflict, "Conflito de dados"),
_ => (StatusCodes.Status500InternalServerError, "Erro interno do servidor")
};
if (statusCode == 500)
_logger.LogError(exception, "Exceção não tratada: {Message}", exception.Message);
else
_logger.LogWarning("Exceção de negócio: {Type} — {Message}",
exception.GetType().Name, exception.Message);
var problemDetails = new ProblemDetails
{
Status = statusCode,
Title = title,
Detail = exception.Message,
Instance = context.Request.Path
};
// Adiciona correlation ID para rastreabilidade
if (context.Items.TryGetValue("CorrelationId", out var correlationId))
problemDetails.Extensions["correlationId"] = correlationId;
context.Response.StatusCode = statusCode;
await context.Response.WriteAsJsonAsync(problemDetails, cancellationToken);
return true; // true = exceção foi tratada, não propaga
}
}
// Registrar no Program.cs
builder.Services.AddExceptionHandler<GlobalExceptionHandler>();
builder.Services.AddProblemDetails();
app.UseExceptionHandler(); // Usa o IExceptionHandler registrado
Middleware de rate limiting por IP customizado
// Para casos onde o RateLimiter nativo não é suficiente
public class IpRateLimitMiddleware
{
private readonly RequestDelegate _next;
private readonly IMemoryCache _cache;
private readonly ILogger<IpRateLimitMiddleware> _logger;
private const int MaxRequests = 100;
private static readonly TimeSpan Window = TimeSpan.FromMinutes(1);
public IpRateLimitMiddleware(
RequestDelegate next,
IMemoryCache cache,
ILogger<IpRateLimitMiddleware> logger)
{
_next = next;
_cache = cache;
_logger = logger;
}
public async Task InvokeAsync(HttpContext context)
{
// Respeita X-Forwarded-For (quando atrás de reverse proxy)
var ipAddress = context.Request.Headers["X-Forwarded-For"].FirstOrDefault()
?? context.Connection.RemoteIpAddress?.ToString()
?? "unknown";
var cacheKey = $"ratelimit:{ipAddress}";
var count = _cache.GetOrCreate(cacheKey, entry =>
{
entry.AbsoluteExpirationRelativeToNow = Window;
return 0;
});
if (count >= MaxRequests)
{
_logger.LogWarning("Rate limit atingido para IP {IpAddress}", ipAddress);
context.Response.StatusCode = StatusCodes.Status429TooManyRequests;
context.Response.Headers["Retry-After"] = "60";
await context.Response.WriteAsJsonAsync(new ProblemDetails
{
Status = 429,
Title = "Too Many Requests",
Detail = $"Limite de {MaxRequests} requisições por minuto atingido."
});
return;
}
_cache.Set(cacheKey, count + 1, new MemoryCacheEntryOptions
{
AbsoluteExpiration = DateTimeOffset.UtcNow.Add(Window)
});
// Adiciona headers informativos
context.Response.Headers["X-RateLimit-Limit"] = MaxRequests.ToString();
context.Response.Headers["X-RateLimit-Remaining"] = (MaxRequests - count - 1).ToString();
await _next(context);
}
}
Testando middlewares
// Teste unitário de middleware sem stack HTTP completo
public class CorrelationIdMiddlewareTests
{
[Fact]
public async Task Invoke_NoCorrelationIdInRequest_GeneratesNewId()
{
// Arrange
var context = new DefaultHttpContext();
var correlationIdService = new Mock<ICorrelationIdService>();
string? capturedId = null;
correlationIdService
.Setup(s => s.SetCurrentId(It.IsAny<string>()))
.Callback<string>(id => capturedId = id);
var logger = NullLogger<CorrelationIdMiddleware>.Instance;
var nextCalled = false;
RequestDelegate next = ctx => { nextCalled = true; return Task.CompletedTask; };
var middleware = new CorrelationIdMiddleware(next, logger);
// Act
await middleware.InvokeAsync(context, correlationIdService.Object);
// Assert
nextCalled.Should().BeTrue();
capturedId.Should().NotBeNullOrEmpty();
context.Response.Headers["X-Correlation-ID"].ToString().Should().Be(capturedId);
}
[Fact]
public async Task Invoke_CorrelationIdInRequest_UsesExistingId()
{
var expectedId = "existing-correlation-id-123";
var context = new DefaultHttpContext();
context.Request.Headers["X-Correlation-ID"] = expectedId;
var correlationIdService = new Mock<ICorrelationIdService>();
string? capturedId = null;
correlationIdService
.Setup(s => s.SetCurrentId(It.IsAny<string>()))
.Callback<string>(id => capturedId = id);
var middleware = new CorrelationIdMiddleware(
_ => Task.CompletedTask,
NullLogger<CorrelationIdMiddleware>.Instance);
await middleware.InvokeAsync(context, correlationIdService.Object);
capturedId.Should().Be(expectedId);
}
}
Ordem correta do pipeline — regra de ouro
| Posição | Middleware | Por quê aqui? |
|---|---|---|
| 1º | ExceptionHandler | Deve capturar exceções de tudo abaixo |
| 2º | HttpsRedirection | Antes de qualquer processamento real |
| 3º | StaticFiles | Sem auth — resposta rápida sem passar pelo pipeline todo |
| 4º | Routing | Resolve o endpoint antes da autenticação |
| 5º | CorrelationId | Antes de qualquer log — enriches context |
| 6º | Authentication | Depois do routing (sabe qual endpoint será chamado) |
| 7º | Authorization | Após autenticação |
| 8º | RateLimiter | Após auth — rate limit pode ser por usuário autenticado |
| 9º | TenantResolution | Após auth — tenant pode depender do usuário |
| 10º | RequestLogging | Com contexto completo (usuário, tenant, correlation ID) |
| Último | MapControllers/Endpoints | Handler final |
Middlewares bem projetados eliminam código repetitivo de controllers e garantem comportamento consistente em toda a API. Se você quer uma arquitetura ASP.NET Core mais limpa e manutenível, fale com a Neryx.