Middleware no ASP.NET Core: o que é e erros comuns

Middleware no ASP.NET Core: o que é e erros comuns

O middleware no ASP.NET Core é um componente que intercepta cada requisição HTTP e cada resposta, formando uma cadeia de processamento chamada pipeline. Neste artigo, você entende como essa cadeia funciona, cria um middleware próprio e conhece os erros que mais derrubam aplicações em produção.

O que é middleware no ASP.NET Core

Em termos simples, um middleware no ASP.NET Core é um trecho de código que recebe o HttpContext, executa alguma lógica e decide se passa a requisição adiante. Portanto, ele funciona como uma camada que a requisição atravessa na ida e que a resposta atravessa na volta.

Autenticação, roteamento, arquivos estáticos, compressão e tratamento de exceções são exemplos de middlewares que já vêm prontos no framework. Além disso, você pode escrever os seus próprios componentes para resolver necessidades específicas da aplicação.

Cada middleware no ASP.NET Core tem duas oportunidades de agir. Primeiro, ele atua antes de chamar o próximo componente e, em seguida, atua depois que esse componente termina.

Por isso, um único middleware consegue medir o tempo de uma requisição, registrar logs de entrada e saída ou adicionar cabeçalhos à resposta. Na prática, quase toda funcionalidade transversal de uma API passa por esse mecanismo.

A documentação oficial descreve o conceito em detalhes no guia ASP.NET Core middleware do Microsoft Learn. Neste artigo, todos os exemplos usam o .NET 10, que é a versão LTS atual da plataforma.

Como o pipeline de middleware no ASP.NET Core funciona

O pipeline é a sequência de componentes que a aplicação monta no arquivo Program.cs. Quando uma requisição chega, o servidor Kestrel entrega o HttpContext ao primeiro middleware da lista.

Esse primeiro componente executa sua lógica e chama o delegate next, que representa o próximo middleware da cadeia. Assim, a requisição desce até chegar ao endpoint que gera a resposta.

Depois que o endpoint termina, o controle volta pelo mesmo caminho, só que na ordem inversa. Dessa forma, o primeiro middleware registrado é o primeiro a ver a requisição e o último a ver a resposta.

Um componente também pode decidir não chamar o próximo. Nesse caso, ele encerra o pipeline e recebe o nome de middleware terminal, comportamento comum em autenticação e arquivos estáticos.

Ordem de registro do middleware no ASP.NET Core

A ordem em que você registra cada middleware no ASP.NET Core define a ordem de execução. Por exemplo, o tratamento de exceções precisa vir antes dos demais, pois só captura erros dos componentes registrados depois dele.

Da mesma forma, a autenticação precisa vir antes da autorização. Caso contrário, a autorização avalia um usuário que ainda não foi identificado.

var builder = WebApplication.CreateBuilder(args);
var app = builder.Build();

// Tratamento de erros sempre no início do pipeline
app.UseExceptionHandler("/error");
app.UseHttpsRedirection();

// Autenticação antes da autorização
app.UseAuthentication();
app.UseAuthorization();

app.MapGet("/api/orders", () => Results.Ok());

app.Run();

Em outras palavras, a ordem do pipeline faz parte da regra de negócio da aplicação. Portanto, trate cada linha do Program.cs com o mesmo cuidado que você trata o código de domínio.

Criando seu primeiro middleware no ASP.NET Core

Existem duas formas principais de escrever um middleware no ASP.NET Core. A primeira usa uma expressão lambda direto no Program.cs, enquanto a segunda encapsula a lógica em uma classe dedicada.

Middleware inline com app.Use

A forma mais rápida de criar um middleware é chamar o método app.Use e passar uma lambda. Nesse formato, você recebe o HttpContext e o delegate next como parâmetros.

// Program.cs
app.Use(async (context, next) =>
{
    Console.WriteLine($"Request: {context.Request.Path}");
    await next(context);
    Console.WriteLine($"Response: {context.Response.StatusCode}");
});

O código antes de await next(context) roda na ida, e o código depois roda na volta. Além disso, a documentação recomenda essa sobrecarga que recebe o HttpContext, porque ela economiza duas alocações internas por requisição.

No entanto, a lambda dificulta os testes de unidade e polui o Program.cs à medida que a lógica cresce. Por esse motivo, o caminho mais sustentável é mover o código para uma classe.

Movendo o middleware para uma classe

Uma classe de middleware segue uma convenção simples. Ela precisa de um construtor público que receba um RequestDelegate e de um método público chamado Invoke ou InvokeAsync.

Esse método retorna uma Task e recebe o HttpContext como primeiro parâmetro. Então, a classe equivalente ao exemplo anterior fica assim:

// RequestLoggingMiddleware.cs
public class RequestLoggingMiddleware(
    RequestDelegate next,
    ILogger<RequestLoggingMiddleware> logger)
{
    public async Task InvokeAsync(HttpContext context)
    {
        logger.LogInformation("Request: {Path}", context.Request.Path);
        await next(context);
        logger.LogInformation("Response: {StatusCode}", context.Response.StatusCode);
    }
}

Em seguida, você registra a classe no pipeline com UseMiddleware. Sem esse registro, o framework simplesmente ignora o componente.

// Program.cs
app.UseMiddleware<RequestLoggingMiddleware>();

É comum também criar um método de extensão, como UseRequestLogging, para deixar o Program.cs mais legível. Esse padrão segue o mesmo estilo dos middlewares nativos do framework.

Erro 1: esquecer de chamar o next

O primeiro erro clássico com middleware no ASP.NET Core é esquecer a chamada ao next. Quando isso acontece, o componente vira terminal sem que você perceba.

Imagine que o RequestLoggingMiddleware está registrado no topo do pipeline e que alguém comenta a linha await next(context). A aplicação compila normalmente, mas nenhum endpoint responde mais.

public async Task InvokeAsync(HttpContext context)
{
    logger.LogInformation("Request: {Path}", context.Request.Path);
    // await next(context);  // linha esquecida: o pipeline para aqui
    logger.LogInformation("Response: {StatusCode}", context.Response.StatusCode);
}

Ao chamar a rota /api/test pelo Postman, você recebe um status 200 com o corpo vazio. Isso acontece porque o valor padrão do status code é 200 e nenhum componente escreveu conteúdo na resposta.

Além disso, endpoints, Swagger e qualquer outro middleware registrado depois deixam de executar. Por isso, o sintoma costuma confundir quem investiga, já que não aparece nenhuma exceção no log.

A correção é direta: devolva a chamada ao next. Contudo, vale a pena criar um teste de integração simples que valide se a rota principal da API ainda responde, porque ele pega esse tipo de regressão rapidamente.

Erro 2: serviços scoped no construtor do middleware no ASP.NET Core

O segundo erro envolve injeção de dependência. Um middleware no ASP.NET Core criado por convenção nasce uma única vez, na inicialização da aplicação, e vive até ela terminar.

Ou seja, a classe se comporta como um singleton. Portanto, se você pedir um serviço scoped no construtor, o container tenta resolvê-lo a partir do provedor raiz.

// Program.cs
builder.Services.AddScoped<IAuditService, AuditService>();

// RequestLoggingMiddleware.cs (versão com erro)
public class RequestLoggingMiddleware(
    RequestDelegate next,
    IAuditService auditService) // scoped no construtor
{
    // ...
}

Em ambiente de desenvolvimento, a validação de escopo do container detecta o problema e lança uma exceção na inicialização. A mensagem informa que não é possível resolver o serviço scoped a partir do provedor raiz.

Sem essa validação, o cenário fica ainda pior. Nesse caso, o serviço scoped se torna um singleton disfarçado e passa a compartilhar estado entre requisições de usuários diferentes.

Para corrigir, mova o serviço para a assinatura do InvokeAsync. O framework resolve os parâmetros extras desse método a partir do escopo da requisição atual.

public class RequestLoggingMiddleware(
    RequestDelegate next,
    ILogger<RequestLoggingMiddleware> logger)
{
    public async Task InvokeAsync(
        HttpContext context,
        IAuditService auditService) // resolvido a cada requisição
    {
        await auditService.RegisterAsync(context.Request.Path);
        await next(context);
    }
}

Como regra prática, dependências singleton ficam no construtor e dependências scoped ou transient ficam no InvokeAsync. Um DbContext do Entity Framework, por exemplo, sempre vai para o método.

Alternativa com IMiddleware

Existe uma segunda abordagem, chamada ativação por fábrica. Nela, a classe implementa a interface IMiddleware e o container cria uma nova instância a cada requisição.

public class AuditMiddleware(IAuditService auditService) : IMiddleware
{
    public async Task InvokeAsync(HttpContext context, RequestDelegate next)
    {
        await auditService.RegisterAsync(context.Request.Path);
        await next(context);
    }
}

// Program.cs
builder.Services.AddScoped<AuditMiddleware>();
app.UseMiddleware<AuditMiddleware>();

Com isso, o construtor aceita serviços scoped sem nenhum problema e a classe ganha tipagem forte. Por outro lado, você precisa registrar o próprio middleware no container, caso contrário a aplicação falha ao montar o pipeline.

Erro 3: alterar a resposta depois que ela começou

O ASP.NET Core não guarda o corpo da resposta em buffer por padrão. Assim, na primeira escrita, o servidor envia os cabeçalhos e o status code ao cliente junto com o primeiro bloco de dados.

A partir desse momento, a resposta está iniciada e os cabeçalhos ficam somente leitura. Mesmo assim, é comum encontrar um middleware no ASP.NET Core que tenta mudar o status depois de chamar o next.

Esse erro aparece com frequência em componentes de log, de métricas e de padronização de respostas. Afinal, o desenvolvedor quer olhar o resultado do endpoint e, em seguida, ajustar algum detalhe.

public async Task InvokeAsync(HttpContext context)
{
    await next(context);

    // O endpoint já escreveu a resposta: esta linha lança exceção
    context.Response.StatusCode = StatusCodes.Status503ServiceUnavailable;
}

Esse código lança uma InvalidOperationException informando que o status code não pode mudar porque a resposta já começou. Além disso, escrever no corpo depois do next pode corromper o formato do conteúdo ou violar o cabeçalho Content-Length.

A primeira proteção é verificar a propriedade HasStarted antes de qualquer alteração. Se ela for verdadeira, o middleware deve apenas registrar a situação e seguir em frente.

A segunda proteção é usar context.Response.OnStarting. Esse método registra um callback que o framework executa imediatamente antes de enviar os cabeçalhos, ou seja, a última chance de alterá-los.

public async Task InvokeAsync(HttpContext context)
{
    context.Response.OnStarting(() =>
    {
        // Última oportunidade para mexer em cabeçalhos e status
        context.Response.Headers["X-Request-Id"] = context.TraceIdentifier;
        return Task.CompletedTask;
    });

    await next(context);
}

Agora, se o objetivo for exibir uma página de manutenção, a melhor solução não envolve OnStarting. Nesse caso, o middleware deve interromper o pipeline antes de chamar o next e escrever a própria resposta.

public class MaintenanceMiddleware(RequestDelegate next, IConfiguration configuration)
{
    public async Task InvokeAsync(HttpContext context)
    {
        if (configuration.GetValue<bool>("Maintenance:Enabled"))
        {
            // Encerra o pipeline sem chamar o próximo componente
            context.Response.StatusCode = StatusCodes.Status503ServiceUnavailable;
            await context.Response.WriteAsync("Service temporarily unavailable.");
            return;
        }

        await next(context);
    }
}

Dessa forma, o endpoint nunca executa e não existe disputa pela resposta. Em resumo, use OnStarting para cabeçalhos e status, e use o curto-circuito para substituir o conteúdo inteiro.

Erro 4: reter serviços scoped após o fim da requisição

O último erro é mais silencioso e costuma passar despercebido em testes manuais. Ele surge quando um middleware no ASP.NET Core dispara um trabalho em segundo plano que usa um serviço scoped da requisição.

Para entender o problema, lembre que o container cria um escopo para cada requisição HTTP. Quando a resposta termina, o framework descarta esse escopo e chama o Dispose de cada serviço scoped e transient criado nele.

O método context.Response.OnCompleted registra um callback que roda depois que a resposta termina. Por exemplo, alguém pode usá-lo para enviar um evento de auditoria alguns segundos depois.

public async Task InvokeAsync(HttpContext context, IAuditService auditService)
{
    context.Response.OnCompleted(() =>
    {
        _ = Task.Run(async () =>
        {
            await Task.Delay(TimeSpan.FromSeconds(5));
            // O escopo da requisição já terminou: o serviço está descartado
            await auditService.RegisterAsync("late event");
        });

        return Task.CompletedTask;
    });

    await next(context);
}

À primeira vista, tudo funciona e a página carrega normalmente. Contudo, quando a tarefa atrasada executa, o container já descartou o escopo da requisição e todos os serviços scoped junto com ele.

Se o serviço implementa IDisposable e valida o próprio estado, o log mostra a exceção ObjectDisposedException. Caso contrário, o código pode até parecer funcionar, mas usa um objeto em estado inválido, como um DbContext com a conexão fechada.

Criando um escopo novo para o trabalho em segundo plano

A correção é criar um escopo novo para o trabalho em segundo plano. Para isso, injete o IServiceScopeFactory no construtor, já que ele é um singleton e pode viver junto com o middleware.

public class AuditMiddleware(
    RequestDelegate next,
    IServiceScopeFactory scopeFactory,
    ILogger<AuditMiddleware> logger)
{
    public async Task InvokeAsync(HttpContext context)
    {
        // Copia os dados necessários antes de sair da requisição
        var path = context.Request.Path.ToString();

        context.Response.OnCompleted(() =>
        {
            _ = Task.Run(async () =>
            {
                // Novo escopo, independente da requisição original
                await using var scope = scopeFactory.CreateAsyncScope();
                var auditService = scope.ServiceProvider.GetRequiredService<IAuditService>();

                try
                {
                    await auditService.RegisterAsync(path);
                }
                catch (Exception ex)
                {
                    logger.LogError(ex, "Failed to register audit for {Path}", path);
                }
            });

            return Task.CompletedTask;
        });

        await next(context);
    }
}

Repare que o código copia o caminho da requisição para uma variável local antes de iniciar a tarefa. Isso importa porque o HttpContext não é thread safe e o framework também recicla esse objeto ao fim da requisição.

Outro detalhe importante é o tratamento de exceções dentro da tarefa. Como ninguém aguarda o Task.Run, uma exceção não tratada ali desaparece sem deixar rastro, por isso o bloco try com log é obrigatório.

Em cenários de produção, uma alternativa ainda mais robusta é publicar o evento em uma fila com Channel<T> e processá-lo em um BackgroundService. Assim, você ganha controle sobre falhas, novas tentativas e o desligamento gracioso da aplicação.

Boas práticas para middleware no ASP.NET Core

Depois dos erros, vale reunir as práticas que evitam a maior parte dos problemas. Antes de tudo, mantenha cada middleware no ASP.NET Core com uma única responsabilidade.

  • Chame sempre o next, a menos que o componente precise encerrar o pipeline de propósito.
  • Registre os componentes na ordem correta, com tratamento de erros no início e endpoints no fim.
  • Coloque dependências singleton no construtor e dependências scoped no InvokeAsync.
  • Verifique HasStarted antes de alterar a resposta depois do next.
  • Use OnStarting para cabeçalhos e o curto-circuito para respostas completas.
  • Crie um escopo novo para qualquer trabalho que ultrapasse a vida da requisição.

Além disso, prefira classes a lambdas sempre que a lógica passar de poucas linhas. Uma classe facilita os testes, a leitura e a reutilização entre projetos.

Também vale lembrar que nem toda lógica transversal precisa virar middleware. Em Minimal APIs, por exemplo, os endpoint filters atuam apenas sobre rotas específicas e têm acesso aos argumentos já convertidos.

Se você quer aprofundar esses fundamentos com projetos completos, os cursos de .NET do balta.io cobrem APIs, injeção de dependência e arquitetura de aplicações web.

Conclusão

O middleware no ASP.NET Core é a base de todo o processamento HTTP da plataforma. Entender o pipeline, a ordem de registro e o ciclo de vida de cada componente evita bugs difíceis de rastrear.

Os quatro erros deste artigo têm algo em comum: o código compila e, muitas vezes, parece funcionar. Por isso, conhecer o comportamento interno do framework vale mais do que qualquer ferramenta de depuração.

Em resumo, chame o next, respeite os tempos de vida dos serviços, não toque em uma resposta iniciada e crie escopos próprios para tarefas em segundo plano. Com essas regras, cada middleware no ASP.NET Core que você escrever fica previsível, testável e seguro em produção.

FAQ

O que é middleware no ASP.NET Core?

É um componente que recebe o HttpContext, executa uma lógica e decide se chama o próximo componente do pipeline. Juntos, os middlewares processam toda requisição e resposta HTTP da aplicação.

Qual a diferença entre app.Use e app.Run?

O app.Use recebe o delegate next e pode continuar o pipeline. Já o app.Run não recebe esse delegate e sempre encerra o processamento.

Por que não posso injetar um serviço scoped no construtor do middleware?

Porque o middleware convencional nasce uma única vez e vive durante toda a aplicação. Assim, injete serviços scoped no método InvokeAsync ou use a interface IMiddleware.

Como alterar cabeçalhos da resposta com segurança?

Registre um callback com Response.OnStarting antes de chamar o next. Se precisar alterar algo depois, verifique antes se Response.HasStarted é falso.

Quando usar IMiddleware em vez da convenção?

Use IMiddleware quando o construtor precisa de serviços scoped ou quando você quer tipagem forte. Lembre de registrar a classe no container de injeção de dependência.