Remigiusz ZalewskiRemigiusz Zalewski

Exception middleware in .NET

exception-handling-middleware-dotnet

Introduction

Without a central handler, an unhandled exception in ASP.NET Core either returns a raw stack trace (in development) or a bare 500 with no body (in production), and every developer ends up wrapping controller actions in try/catch to paper over it. That scatters error-handling logic everywhere and still misses the exceptions thrown deeper in the stack.

This video builds one piece of middleware that sits at the top of the pipeline, catches anything that bubbles up, maps the exception type to an HTTP status code, logs it with a correlation id, and returns a consistent JSON error response. Your endpoints go back to throwing exceptions and trusting something will handle them.

🎬 Watch the full video here:


The middleware

Custom middleware is a class with an InvokeAsync(HttpContext) method that calls the next delegate. Wrapping that call in try/catch is what makes it an exception handler:

public async Task InvokeAsync(HttpContext context)
{
    try
    {
        await _next(context);
    }
    catch (Exception ex)
    {
        await HandleExceptionAsync(context, ex);
    }
}

Because it wraps _next, it sees every exception thrown by anything further down the pipeline - endpoints, filters, other middleware, services they call.

Mapping exception types to status codes

The handler switches on the exception type to decide the response status:

StatusCode = exception switch
{
    PlayerNotFoundException => HttpStatusCode.NotFound,
    UnauthorizedAccessException => HttpStatusCode.Unauthorized,
    _ => HttpStatusCode.InternalServerError
};

This is the pattern that lets your domain code throw meaningful exceptions - NotFoundException, ValidationException, ConflictException - and have them become the right HTTP response in exactly one place. A custom exception is just a class deriving from Exception.

A consistent error body

Every error returns the same shape, serialized as JSON:

public class Error
{
    public Guid ErrorId { get; set; } = Guid.NewGuid();
    public string Message { get; set; }
    public string ExceptionType { get; set; }
    public HttpStatusCode StatusCode { get; set; }
    public string? StackTrace { get; set; }
}

The ErrorId is the useful bit: log it alongside the full exception, return it to the client, and a support request that quotes that id points you straight at the log entry.

Register it first

app.UseMiddleware<ExceptionHandlingMiddleware>();

Order matters. It has to be registered before the middleware and endpoints whose exceptions you want to catch - ideally first in the pipeline - so nothing escapes above it.

Do not return the stack trace in production

The demo includes StackTrace in the response, which is fine for a teaching build but a real information leak in production - it exposes file paths, library versions, and internal structure. Gate it: include the stack trace only when env.IsDevelopment(), and in production return just the ErrorId, a generic message, and the status code. The full detail belongs in your logs, not the response.

The built-in alternatives

.NET has framework support for this now, and it is worth knowing:

  • IExceptionHandler (.NET 8+): register a class implementing IExceptionHandler with AddExceptionHandler<T>() and UseExceptionHandler(). Same idea, less plumbing, and you can chain multiple handlers.
  • Problem Details (AddProblemDetails()): produces RFC 9457 application/problem+json responses, the standard error format. Prefer this over a bespoke Error class for a new API.

The hand-rolled middleware in this video is the right thing to understand first - the built-ins do the same job with the same lifecycle.

Common pitfalls

  • Registering it too late. Anything before it in the pipeline is outside its try.
  • Leaking stack traces and messages. Some exception messages contain sensitive detail; sanitize for production.
  • Swallowing the exception without logging. Always log the full exception with the ErrorId before you shorten it for the client.
  • Catching and rethrowing in endpoints anyway. Once the middleware exists, delete the scattered try/catch blocks or they defeat the point.
  • One giant _ => 500. Add cases for your real domain exceptions so clients get actionable status codes.

Key Takeaways

  • One piece of middleware wrapping _next in try/catch handles every unhandled exception in the app.
  • Switch on exception type to map domain exceptions to HTTP status codes in a single place.
  • Return a consistent JSON error body with a generated ErrorId you also write to the logs.
  • Register the middleware first so nothing escapes above it.
  • Never return stack traces or raw exception messages in production - log them, return the id.
  • Consider the built-in IExceptionHandler and AddProblemDetails() for new APIs; they do this with less code.

Get the Full Source Code

The complete runnable solution - the middleware, the custom exceptions, the error model, and endpoints that throw on purpose - is available to Patreon supporters. If you want to hit the failing endpoints and see the shaped responses instead of rebuilding it from the walkthrough above, you can find it on Patreon.

Resources