Implementing Robust Error Handling in ASP.NET Core Applications

Why Error Handling Matters

If you've ever shipped an API only to wake up to cryptic 500 errors with stack traces leaking to users, you know the pain of inadequate error handling. Generic exceptions crash requests, expose internal details, and leave your team guessing what went wrong. Users see broken pages, and your logs fill with noise instead of actionable data.

This article shows how ASP.NET Core's middleware and exception filters remove that pain by catching errors gracefully, returning clean responses, and logging useful context. You'll build safer APIs that fail predictably while keeping internal details private. Proper error handling turns chaotic failures into manageable incidents with clear remediation paths.

You'll learn global exception middleware, custom exception filters, ProblemDetails responses for REST APIs, and structured logging patterns that make debugging faster. By the end, you'll have production-ready error handling you can drop into any ASP.NET Core project.

Setting Up Global Exception Middleware

The simplest way to handle errors across your entire application is global exception middleware. This catches any unhandled exception before it reaches the client and lets you return a consistent error response. ASP.NET Core provides UseExceptionHandler middleware that intercepts exceptions and re-executes the request pipeline to a custom error endpoint.

You configure this in Program.cs early in the middleware pipeline. The order matters because middleware runs top to bottom, so place error handling near the start to catch exceptions from later middleware and endpoints.

Program.cs
using Microsoft.AspNetCore.Diagnostics;
using System.Text.Json;

var builder = WebApplication.CreateBuilder(args);
builder.Services.AddControllers();

var app = builder.Build();

// Use detailed errors in development only
if (app.Environment.IsDevelopment())
{
    app.UseDeveloperExceptionPage();
}
else
{
    // Production error handling
    app.UseExceptionHandler("/error");
}

app.UseRouting();
app.MapControllers();

// Error endpoint that middleware redirects to
app.MapGet("/error", (HttpContext context) =>
{
    var exception = context.Features.Get()?.Error;

    var problemDetails = new
    {
        Status = 500,
        Title = "An error occurred",
        Detail = app.Environment.IsDevelopment()
            ? exception?.Message
            : "An unexpected error occurred. Please try again later."
    };

    context.Response.StatusCode = 500;
    context.Response.ContentType = "application/json";

    return Results.Json(problemDetails);
});

app.Run();

This configuration shows detailed errors during development but returns safe generic messages in production. The error endpoint retrieves the original exception from IExceptionHandlerFeature and formats it into a JSON response. Clients get consistent error structures regardless of where the exception originated.

Using ProblemDetails for RESTful APIs

ProblemDetails is an RFC 7807 standard for machine-readable error responses in HTTP APIs. It provides a consistent format with type, title, status, detail, and instance fields. ASP.NET Core has built-in support through the ProblemDetails class and middleware.

For APIs, you should return ProblemDetails instead of plain JSON. This gives clients a predictable structure and helps with debugging by including correlation IDs and error categories.

ErrorHandler.cs
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Diagnostics;

var builder = WebApplication.CreateBuilder(args);
builder.Services.AddControllers();
builder.Services.AddProblemDetails();

var app = builder.Build();

if (!app.Environment.IsDevelopment())
{
    app.UseExceptionHandler();
}

app.MapGet("/error", (HttpContext context, IHostEnvironment env) =>
{
    var exception = context.Features.Get()?.Error;

    var problemDetails = new ProblemDetails
    {
        Status = StatusCodes.Status500InternalServerError,
        Title = "Server Error",
        Type = "https://tools.ietf.org/html/rfc7231#section-6.6.1",
        Detail = env.IsDevelopment()
            ? exception?.ToString()
            : "An error occurred processing your request."
    };

    problemDetails.Extensions["traceId"] = context.TraceIdentifier;

    return Results.Problem(problemDetails);
}).ExcludeFromDescription();

app.MapControllers();
app.Run();

The ProblemDetails response includes a trace identifier that matches server logs, making it easy to correlate client errors with server-side exceptions. The type field links to HTTP specification documentation, and you can add custom extensions for additional context like error codes or validation failures.

Creating Custom Exception Filters

Exception filters run within the MVC pipeline and give you access to controller context, model state, and action descriptors. They're perfect when you need different error handling based on controller type or want to transform domain exceptions into specific HTTP responses.

Filters implement IExceptionFilter or IAsyncExceptionFilter and can be applied globally, per controller, or per action. They run after middleware but before the response is sent, giving you a last chance to shape error responses.

CustomExceptionFilter.cs
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.Filters;

namespace MyApi.Filters;

public class CustomExceptionFilter : IExceptionFilter
{
    private readonly ILogger _logger;
    private readonly IHostEnvironment _env;

    public CustomExceptionFilter(
        ILogger logger,
        IHostEnvironment env)
    {
        _logger = logger;
        _env = env;
    }

    public void OnException(ExceptionContext context)
    {
        _logger.LogError(
            context.Exception,
            "Unhandled exception in {Controller}.{Action}",
            context.RouteData.Values["controller"],
            context.RouteData.Values["action"]);

        var problemDetails = context.Exception switch
        {
            ValidationException ex => CreateValidationError(ex),
            NotFoundException ex => CreateNotFoundError(ex),
            UnauthorizedAccessException => CreateUnauthorizedError(),
            _ => CreateServerError(context.Exception)
        };

        context.Result = new ObjectResult(problemDetails)
        {
            StatusCode = problemDetails.Status
        };

        context.ExceptionHandled = true;
    }

    private ProblemDetails CreateValidationError(ValidationException ex)
    {
        var details = new ProblemDetails
        {
            Status = StatusCodes.Status400BadRequest,
            Title = "Validation Failed",
            Detail = ex.Message
        };

        if (ex.Errors?.Any() == true)
        {
            details.Extensions["errors"] = ex.Errors;
        }

        return details;
    }

    private ProblemDetails CreateNotFoundError(NotFoundException ex)
    {
        return new ProblemDetails
        {
            Status = StatusCodes.Status404NotFound,
            Title = "Resource Not Found",
            Detail = ex.Message
        };
    }

    private ProblemDetails CreateUnauthorizedError()
    {
        return new ProblemDetails
        {
            Status = StatusCodes.Status401Unauthorized,
            Title = "Unauthorized",
            Detail = "Authentication required to access this resource."
        };
    }

    private ProblemDetails CreateServerError(Exception ex)
    {
        return new ProblemDetails
        {
            Status = StatusCodes.Status500InternalServerError,
            Title = "Server Error",
            Detail = _env.IsDevelopment()
                ? ex.Message
                : "An unexpected error occurred."
        };
    }
}

// Custom exception types
public class ValidationException : Exception
{
    public Dictionary? Errors { get; set; }
    public ValidationException(string message) : base(message) { }
}

public class NotFoundException : Exception
{
    public NotFoundException(string message) : base(message) { }
}

This filter maps different exception types to appropriate HTTP status codes and ProblemDetails responses. ValidationException becomes 400 Bad Request, NotFoundException becomes 404, and UnauthorizedAccessException becomes 401. All other exceptions default to 500 Internal Server Error. The filter logs every exception with controller and action context before transforming it into a response.

Mistakes to Avoid

Forgetting to set ExceptionHandled to true in filters causes ASP.NET Core to continue propagating the exception. After you handle an exception and set context.Result, always mark context.ExceptionHandled = true. Otherwise, the exception bubbles up to middleware and you get duplicate error responses or unhandled exception crashes.

Returning different error formats between middleware and filters confuses API clients. Standardize on ProblemDetails across your entire error handling stack. Whether errors come from middleware, filters, or manual validation, clients should receive the same JSON structure. This consistency makes client-side error handling straightforward.

Exposing stack traces and exception details in production leaks implementation details and creates security risks. Always check the environment before including sensitive information. Use structured logging to capture full exception details server-side while sending sanitized messages to clients. Your logs should have everything needed for debugging without clients seeing it.

Real-World Integration

In production applications, you'll typically combine global middleware for catching everything with exception filters for specific domain logic. Register your exception filter globally in Program.cs so it applies to all controllers.

Wire up structured logging with correlation IDs to track requests across microservices. Add the filter to your service collection and enable ProblemDetails generation for consistent API responses.

Program.cs - Complete setup
using MyApi.Filters;
using Serilog;

var builder = WebApplication.CreateBuilder(args);

// Configure structured logging
Log.Logger = new LoggerConfiguration()
    .WriteTo.Console()
    .WriteTo.File("logs/app.log", rollingInterval: RollingInterval.Day)
    .CreateLogger();

builder.Host.UseSerilog();

// Register exception filter globally
builder.Services.AddControllers(options =>
{
    options.Filters.Add();
});

builder.Services.AddProblemDetails();

var app = builder.Build();

// Development vs Production
if (app.Environment.IsDevelopment())
{
    app.UseDeveloperExceptionPage();
}
else
{
    app.UseExceptionHandler("/error");
    app.UseHsts();
}

app.MapGet("/error", () => Results.Problem(
    title: "An error occurred",
    statusCode: 500
)).ExcludeFromDescription();

app.MapControllers();
app.Run();

This setup gives you layered error handling. Middleware catches anything that escapes the MVC pipeline like routing failures or middleware exceptions. The exception filter handles controller-specific errors with domain logic. Structured logging with Serilog captures rich context including correlation IDs, timestamps, and exception details. Your error responses stay clean while your logs contain everything you need for troubleshooting.

Try It Yourself

Build a minimal API with error handling middleware and test different exception scenarios. This hands-on example shows how UseExceptionHandler catches unhandled exceptions and returns ProblemDetails responses.

Steps

  1. Create the project: dotnet new web -n ErrorHandlingDemo
  2. Navigate to folder: cd ErrorHandlingDemo
  3. Replace Program.cs with the code below
  4. Run the application: dotnet run
  5. Test endpoints with curl:
    • curl http://localhost:5000/success
    • curl http://localhost:5000/error
    • curl http://localhost:5000/validation
Program.cs
using Microsoft.AspNetCore.Diagnostics;
using Microsoft.AspNetCore.Mvc;

var builder = WebApplication.CreateBuilder(args);
builder.Services.AddProblemDetails();

var app = builder.Build();

app.UseExceptionHandler(exceptionApp =>
{
    exceptionApp.Run(async context =>
    {
        var exception = context.Features
            .Get()?.Error;

        var problemDetails = new ProblemDetails
        {
            Status = 500,
            Title = "Server Error",
            Detail = exception?.Message ?? "An error occurred",
            Instance = context.Request.Path
        };

        problemDetails.Extensions["traceId"] = context.TraceIdentifier;

        context.Response.StatusCode = 500;
        await context.Response.WriteAsJsonAsync(problemDetails);
    });
});

app.MapGet("/success", () => Results.Ok(new { message = "Success!" }));

app.MapGet("/error", () =>
{
    throw new InvalidOperationException("Something went wrong");
});

app.MapGet("/validation", () =>
{
    throw new ArgumentException("Invalid input provided");
});

app.Run();
ErrorHandlingDemo.csproj
<Project Sdk="Microsoft.NET.Sdk.Web">
  <PropertyGroup>
    <TargetFramework>net8.0</TargetFramework>
    <Nullable>enable</Nullable>
    <ImplicitUsings>enable</ImplicitUsings>
  </PropertyGroup>
</Project>

What you'll see

The /success endpoint returns a 200 OK response with JSON. The /error and /validation endpoints throw exceptions that the middleware catches and converts to ProblemDetails responses with status 500. Each response includes a traceId matching your server logs, making it easy to correlate errors. The structured format gives clients predictable error handling regardless of the exception type.

Troubleshooting

Should I use exception filters or middleware for error handling?

Use middleware for application-wide errors and unhandled exceptions. Use exception filters when you need MVC-specific handling or access to action context. Middleware runs earlier in the pipeline and catches everything, while filters give you more granular control within the MVC framework.

How do I prevent sensitive data from appearing in error responses?

Check the environment in your error handler. In production, return generic messages without stack traces or internal details. Use structured logging to capture full exception details server-side while sending safe messages to clients. Never expose connection strings, passwords, or file paths.

What's the difference between UseExceptionHandler and UseDeveloperExceptionPage?

UseDeveloperExceptionPage shows detailed error pages with stack traces and is only for development. UseExceptionHandler provides production-safe error handling with custom error pages or API responses. Always check the environment and use the appropriate middleware for each scenario.

Back to Articles