Skip to main content
SEMastery
ASP.NETbeginner

Global Error Handling in ASP.NET Core 8 (Beginner Guide)

Learn global error handling in ASP.NET Core 8 with IExceptionHandler, ProblemDetails, and UseExceptionHandler, explained with simple diagrams and clear code.

12 min readUpdated December 13, 2025

A school nurse for your app

Imagine a big school with many classrooms. Children sometimes fall and scrape a knee. Now picture two ways the school could deal with this.

In the first way, every single teacher keeps their own first-aid box, their own bandages, and their own rules. One teacher uses a blue bandage. Another shouts. Another forgets and sends the child home. The care is messy and different in every room.

In the second way, the school has one nurse's room. Whenever a child is hurt, no matter which class they came from, they go to the same nurse. The nurse checks the hurt, gives the right care, writes it in a notebook, and sends the child back calm and safe. Every child gets the same kind, careful treatment.

Global error handling in ASP.NET Core is that nurse's room. Instead of every controller and method having its own messy try-catch, your app sends every error to one place. That place checks the error, writes it in a log, and sends back a clean, friendly response. Your normal code stays neat, and every error answer looks the same.

In this guide we will build that nurse's room step by step. We will use the modern tools in ASP.NET Core 8: the exception handler middleware, the new IExceptionHandler interface, and ProblemDetails.

Why not just use try-catch everywhere?

You can wrap every method in try-catch. It works. But it has real problems.

Look at this common, painful style:

[HttpGet("{id}")]
public async Task<IActionResult> GetUser(int id)
{
    try
    {
        var user = await _service.GetUserAsync(id);
        return Ok(user);
    }
    catch (UserNotFoundException)
    {
        return NotFound("User not found");
    }
    catch (Exception ex)
    {
        _logger.LogError(ex, "Something broke");
        return StatusCode(500, "Server error");
    }
}

Now imagine writing those same catch blocks in fifty methods. If you want to change the error shape, you must edit all fifty. If one developer forgets a catch, that endpoint behaves differently. Your real logic (one line!) is buried under error code.

The table below shows the difference between the two styles.

Pointtry-catch everywhereGlobal handling
Where errors are caughtIn every methodIn one central place
Code repetitionVery highAlmost none
Same response shapeHard to keepEasy and automatic
Easy to change laterNo, edit many filesYes, edit one file
Risk of forgettingHighLow

Global handling wins for almost every real app. Let us see how the request actually flows.

A request flows through middleware. The exception handler wraps everything after it and catches errors that bubble up.

The key idea: an error thrown deep inside your code bubbles up through the pipeline. The exception handler sits near the top, so it catches the error and turns it into a tidy response.

Step 1: Turn on ProblemDetails

Before we handle errors, let us decide what an error response should look like. We will use ProblemDetails. This is a standard JSON shape for API errors, set by RFC 7807 (and the newer RFC 9457). Because it is a standard, other tools and apps already know how to read it.

A ProblemDetails response looks like this:

{
  "type": "https://tools.ietf.org/html/rfc7231#section-6.6.1",
  "title": "An error occurred while processing your request.",
  "status": 500,
  "detail": "Object reference not set to an instance of an object.",
  "traceId": "00-abc123..."
}

To switch this on, add one line in Program.cs:

var builder = WebApplication.CreateBuilder(args);
 
builder.Services.AddControllers();
 
// Turn on RFC 7807 ProblemDetails responses.
builder.Services.AddProblemDetails();
 
var app = builder.Build();

AddProblemDetails registers the default IProblemDetailsService. Now ASP.NET Core can build these standard error objects for you.

Step 2: Add the exception handler middleware

Next we wire up the middleware that does the catching. In Program.cs, after builder.Build():

var app = builder.Build();
 
// Catch all errors that happen after this line.
app.UseExceptionHandler();
 
app.MapControllers();
 
app.Run();

One important rule: place UseExceptionHandler() near the start of your pipeline. The handler can only catch errors that happen after it. By putting it first, it wraps everything below, like the nurse's room being right by the school gate.

Wiring up global error handling

AddProblemDetails
AddExceptionHandler
UseExceptionHandler

Steps

1

AddProblemDetails

Enable RFC 7807 error shape

2

AddExceptionHandler

Register your handler class

3

UseExceptionHandler

Put middleware at top of pipeline

The three setup steps you do once in Program.cs.

We have not written our handler class yet, so let us do that now. That is the heart of this guide.

Step 3: Meet IExceptionHandler

ASP.NET Core 8 added a clean interface called IExceptionHandler. It has just one method:

public interface IExceptionHandler
{
    ValueTask<bool> TryHandleAsync(
        HttpContext httpContext,
        Exception exception,
        CancellationToken cancellationToken);
}

The method is called TryHandleAsync. Here is the simple rule for what it returns:

  • Return true: "I handled this error. Stop here."
  • Return false: "I cannot handle this one. Pass it on."

The exception handler middleware calls your handler when an error happens. If you return true, the middleware is done. If you return false, it tries the next handler, and if none handle it, the default behavior runs.

Let us look at that decision as a small flow.

How TryHandleAsync decides what to do with an exception.

Step 4: Write your first handler

Now we build a global handler that catches any error, logs it, and returns a ProblemDetails response. Create a file named GlobalExceptionHandler.cs:

using Microsoft.AspNetCore.Diagnostics;
using Microsoft.AspNetCore.Http;
 
public sealed class GlobalExceptionHandler : IExceptionHandler
{
    private readonly IProblemDetailsService _problemDetailsService;
    private readonly ILogger<GlobalExceptionHandler> _logger;
 
    public GlobalExceptionHandler(
        IProblemDetailsService problemDetailsService,
        ILogger<GlobalExceptionHandler> logger)
    {
        _problemDetailsService = problemDetailsService;
        _logger = logger;
    }
 
    public async ValueTask<bool> TryHandleAsync(
        HttpContext httpContext,
        Exception exception,
        CancellationToken cancellationToken)
    {
        // Write the error to the log so we can investigate later.
        _logger.LogError(
            exception,
            "Unhandled error: {Message}",
            exception.Message);
 
        // Default to 500 Internal Server Error.
        httpContext.Response.StatusCode = StatusCodes.Status500InternalServerError;
 
        // Build and write a clean ProblemDetails response.
        return await _problemDetailsService.TryWriteAsync(
            new ProblemDetailsContext
            {
                HttpContext = httpContext,
                Exception = exception,
                ProblemDetails = new ProblemDetails
                {
                    Type = exception.GetType().Name,
                    Title = "An error occurred",
                    Detail = "Something went wrong. Please try again later.",
                    Status = StatusCodes.Status500InternalServerError
                }
            });
    }
}

Read that slowly. The handler does three jobs, just like the school nurse:

  1. Logs the error (the nurse writes in her notebook).
  2. Sets the status code (decides how serious it is).
  3. Writes a clean ProblemDetails body (sends the child back calm).

Now register it in Program.cs:

builder.Services.AddProblemDetails();
builder.Services.AddExceptionHandler<GlobalExceptionHandler>();

That is it. Every unhandled error in your whole app now goes through GlobalExceptionHandler. Your controllers no longer need try-catch for the general case.

Step 5: Handle different errors differently

A real app has different kinds of errors. A missing user should be a 404. Bad input should be a 400. A true crash should be a 500. The school nurse treats a scraped knee differently from a fever.

The nice thing about IExceptionHandler is that you can register many handlers. They run in the order you register them. The first one that returns true wins. So you can make small, focused handlers.

Here is a handler just for "not found" errors. Imagine you have a custom NotFoundException:

public sealed class NotFoundExceptionHandler : IExceptionHandler
{
    private readonly IProblemDetailsService _problemDetailsService;
 
    public NotFoundExceptionHandler(IProblemDetailsService problemDetailsService)
    {
        _problemDetailsService = problemDetailsService;
    }
 
    public async ValueTask<bool> TryHandleAsync(
        HttpContext httpContext,
        Exception exception,
        CancellationToken cancellationToken)
    {
        // Only handle NotFoundException. Pass everything else on.
        if (exception is not NotFoundException notFound)
        {
            return false;
        }
 
        httpContext.Response.StatusCode = StatusCodes.Status404NotFound;
 
        return await _problemDetailsService.TryWriteAsync(
            new ProblemDetailsContext
            {
                HttpContext = httpContext,
                Exception = exception,
                ProblemDetails = new ProblemDetails
                {
                    Title = "Resource not found",
                    Detail = notFound.Message,
                    Status = StatusCodes.Status404NotFound
                }
            });
    }
}

Notice the guard at the top. If the error is not a NotFoundException, the handler returns false and lets the next handler try. This is how you build a chain.

Register the focused handler first, and the catch-all GlobalExceptionHandler last:

// Order matters. Specific handlers go first.
builder.Services.AddExceptionHandler<NotFoundExceptionHandler>();
builder.Services.AddExceptionHandler<ValidationExceptionHandler>();
builder.Services.AddExceptionHandler<GlobalExceptionHandler>();

The catch-all goes last because it handles everything. If it ran first, the focused handlers would never get a turn.

Chain of exception handlers

NotFound
Validation
Global

Steps

1

NotFound

Handles 404, else passes on

2

Validation

Handles 400, else passes on

3

Global

Catch-all, returns 500

Handlers run in order. The first that returns true wins; the catch-all is last.

Here is the same chain as a flow you can trace with your eyes.

An exception travels through the handler chain until one returns true.

Comparing the old way and the new way

Before .NET 8, people often wrote a custom middleware with a big try-catch inside InvokeAsync. It still works, but it mixes everything into one class. The table below compares the two approaches.

FeatureCustom try-catch middlewareIExceptionHandler
Added inAlways available.NET 8
Split by error typeOne big blockMany small handlers
Works with ProblemDetailsManualBuilt-in support
Recommended for new appsNoYes
Reads cleanlyGets messy fastStays tidy

For brand-new ASP.NET Core 8 projects, reach for IExceptionHandler first. It keeps each piece small and easy to test.

A note on logging and diagnostics

When your handler returns true, it means "this error is handled." In .NET 8 and .NET 9, the framework still emitted some logs and metrics for these handled errors. Starting in .NET 10, the default changed: when TryHandleAsync returns true, the framework now suppresses those extra diagnostic logs and metrics, because you said you handled it.

What does this mean for you? Always log inside your handler yourself, like we did with _logger.LogError. That way you never lose the record, no matter which .NET version you run on. The nurse always writes in her notebook.

Hiding details from users, keeping them in logs

There is one safety habit to learn early. The log can hold the full, scary error with the stack trace. That is for you, the developer. The response sent to the user should be gentle and should not leak inner secrets like database names or file paths.

In our GlobalExceptionHandler, see how the log uses exception.Message (full detail) but the Detail field shown to the user is a calm, generic line. In development you may choose to show more; in production, keep it vague. This protects your app from leaking clues to attackers.

// For the user: calm and generic.
Detail = "Something went wrong. Please try again later.",
 
// For the log: full detail with stack trace.
_logger.LogError(exception, "Unhandled error: {Message}", exception.Message);

Putting the whole Program.cs together

Here is a tidy Program.cs that uses everything we built. This is a good starting shape for a new API.

var builder = WebApplication.CreateBuilder(args);
 
builder.Services.AddControllers();
 
// 1. Standard error shape.
builder.Services.AddProblemDetails();
 
// 2. Handlers run in this order. Specific first, catch-all last.
builder.Services.AddExceptionHandler<NotFoundExceptionHandler>();
builder.Services.AddExceptionHandler<GlobalExceptionHandler>();
 
var app = builder.Build();
 
// 3. Put the catcher near the very top.
app.UseExceptionHandler();
 
app.MapControllers();
 
app.Run();

Now your controllers can throw freely. A missing user can simply throw new NotFoundException("User 5 not found"), and the right handler turns it into a clean 404. No more try-catch clutter in your endpoints.

Common mistakes to avoid

A few traps catch beginners. Keep this short checklist in mind.

  • Forgetting UseExceptionHandler(). Registering handlers is not enough. You must also add the middleware to the pipeline, or nothing runs.
  • Putting the middleware too late. If UseExceptionHandler() comes after the code that throws, the error escapes it. Keep it near the top.
  • Wrong handler order. Register specific handlers first and the catch-all last. Otherwise the catch-all swallows everything.
  • Returning true without writing a response. If you claim you handled it but write nothing, the client gets an empty, confusing reply. Always write a body when you return true.
  • Leaking internal details. Do not send raw stack traces to users in production. Log them, but show a calm message.

Quick recap

  • Global error handling is like one school nurse's room: every error goes to one place and gets the same careful care.
  • Writing try-catch in every method is repetitive and easy to get wrong. Central handling is cleaner.
  • Turn on the standard error shape with builder.Services.AddProblemDetails().
  • ASP.NET Core 8 added IExceptionHandler, an interface with one method, TryHandleAsync.
  • Return true from TryHandleAsync to say "handled," or false to pass the error on.
  • You can register many handlers. They run in order; the first to return true wins. Put specific handlers first and the catch-all last.
  • Add app.UseExceptionHandler() near the start of the pipeline so it wraps everything.
  • Always log the full error yourself, but show users a calm, generic message.
  • From .NET 10, handled exceptions no longer emit extra default diagnostics, so your own logging matters more.

References and further reading

Related Posts