Skip to content
Happy Programming Guide
Start learning
C#

ASP.NET Core Error Handling: Middleware, ProblemDetails and Best Practices

Unhandled exceptions should become consistent, safe JSON responses. The built-in exception handler, ProblemDetails, custom exception mapping and what to log, with working code.

A person working on a laptop

By default an unhandled exception in an ASP.NET Core API produces either a blank 500 or, in development, a full stack trace. Neither is what a client should see. The framework has a complete mechanism for turning exceptions into consistent responses: exception-handling middleware, the standard ProblemDetails format, and an IExceptionHandler interface for mapping your own exceptions to status codes. Set up once, it removes try/catch from controllers entirely.

The minimal setup#

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

var app = builder.Build();

app.UseExceptionHandler();      // catches anything thrown further down the pipeline
app.UseStatusCodePages();       // gives empty 404s and 405s a ProblemDetails body too

app.MapGet("/boom", () => { throw new InvalidOperationException("no"); });
app.Run();

With that, an exception becomes a 500 with a JSON body in the ProblemDetails format, no stack trace in production, and the exception logged. In development, the developer exception page still shows full detail, which is what you want there.

Order matters: UseExceptionHandler must be registered before anything that can throw, which in practice means first. Middleware registered above it is not covered.

What ProblemDetails looks like#

JSON
{
  "type": "https://tools.ietf.org/html/rfc9110#section-15.6.1",
  "title": "An error occurred while processing your request.",
  "status": 500,
  "traceId": "00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-00"
}

It is a standard (RFC 9457), so clients and tooling know how to read it. type identifies the kind of problem, title is a short human summary, status repeats the HTTP code, detail may carry a specific message, and you can add fields. The traceId lets a user quote something support can find in the logs.

Mapping your exceptions#

A domain exception such as “order not found” should become a 404, not a 500. Implement IExceptionHandler:

CSHARP
public sealed class NotFoundExceptionHandler : IExceptionHandler
{
    public async ValueTask<bool> TryHandleAsync(
        HttpContext context, Exception exception, CancellationToken ct)
    {
        if (exception is not NotFoundException nf) return false;   // let the next handler try

        context.Response.StatusCode = StatusCodes.Status404NotFound;
        await context.Response.WriteAsJsonAsync(new ProblemDetails
        {
            Status = 404,
            Title = "Not found",
            Detail = nf.Message,
            Instance = context.Request.Path,
        }, ct);
        return true;                                               // handled
    }
}
CSHARP
builder.Services.AddExceptionHandler<NotFoundExceptionHandler>();
builder.Services.AddExceptionHandler<ConflictExceptionHandler>();
builder.Services.AddProblemDetails();

Handlers run in registration order; the first that returns true wins, and anything unhandled falls through to the default 500. One handler per exception family keeps them small. A single handler with a switch on exception type is the alternative and is fine for small APIs.

Validation errors#

Model-binding failures already produce a 400 with a ValidationProblemDetails body listing each invalid field. For controllers, the [ApiController] attribute does it automatically. For minimal APIs, validate explicitly and return the same shape:

CSHARP
app.MapPost("/orders", (CreateOrder cmd) =>
{
    var errors = new Dictionary<string, string[]>();
    if (cmd.Quantity <= 0) errors["quantity"] = ["Must be greater than zero."];
    if (errors.Count > 0) return Results.ValidationProblem(errors);
    return Results.Created("/orders/1", cmd);
});

Expected failures — validation, not found, conflict — are better returned directly than thrown. Exceptions are for the unexpected. A controller that throws NotFoundException on every missing row works, but Results.NotFound() says the same thing without the cost of an exception.

Adding context to every problem#

CSHARP
builder.Services.AddProblemDetails(options =>
{
    options.CustomizeProblemDetails = ctx =>
    {
        ctx.ProblemDetails.Extensions["requestId"] = ctx.HttpContext.TraceIdentifier;
        ctx.ProblemDetails.Extensions["timestamp"] = DateTimeOffset.UtcNow;
    };
});

Runs for every ProblemDetails response, including the ones the framework generates. This is the place for a support reference or an environment tag.

Logging#

UseExceptionHandler logs unhandled exceptions at Error level with the full stack trace, so you do not need to log again in a handler. For exceptions you map to 4xx, log at Warning or Information — a not-found is not an error in the server. Structured logging with the trace id makes the client’s reference searchable:

CSHARP
logger.LogWarning(exception, "Not found: {Path} trace {TraceId}", context.Request.Path, context.TraceIdentifier);

Best practices, briefly#

  • No try/catch in controllers or endpoints for the general case. Let the middleware handle it.
  • Return results for expected outcomes; throw for unexpected ones.
  • One ProblemDetails shape everywhere, including 404s from routing and 405s, via UseStatusCodePages.
  • Map cancellation: an OperationCanceledException when the client disconnects should be a 499-style early return, not a logged 500.
  • Test the handlers: an integration test that hits an endpoint which throws and asserts on the ProblemDetails body.

Questions people ask#

Do I still need UseDeveloperExceptionPage?

It is added automatically in the Development environment by WebApplication.CreateBuilder. UseExceptionHandler is for every other environment; register both and the framework picks.

Can I use exception filters instead?

Filters work for MVC controllers only and run later in the pipeline. Middleware covers minimal APIs, controllers and everything else, so prefer it.

What status code for a business-rule violation?

Usually 400 for invalid input, 409 for a state conflict such as “already exists”, 422 if you want to distinguish semantic errors from malformed requests. Be consistent across the API.

Is ProblemDetails required?

No, but it is the standard, clients understand it, and the framework produces it for free. Inventing your own error shape gains nothing.

Where to go next#

REST API error handling: which status code, whenRead next

Keep reading

C#

C# Deployment

How to publish a .NET application: framework-dependent versus self-contained builds, single-file output, trimming, Docker and configuration for production.

4 min read

C#

C# Basics

The core of C# in one page: types, variables, conditions, loops, methods, collections and classes, with the compiler errors beginners meet.

5 min read

Keep going — pick your next guide

The fastest way to improve is to read one guide, then build the thing it describes. Start with the basics, or jump straight to a project.

Ask a question or share what worked

Your email address will not be published. Required fields are marked *