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.
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.
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.
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.
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
- Create the project:
dotnet new web -n ErrorHandlingDemo
- Navigate to folder:
cd ErrorHandlingDemo
- Replace Program.cs with the code below
- Run the application:
dotnet run
- Test endpoints with curl:
curl http://localhost:5000/success
curl http://localhost:5000/error
curl http://localhost:5000/validation
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();
<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.