Mert Özen Further With Every Line
Backend .NET Core Web API — #13

Global Error Handling: Catching All Errors in a Single Place

Mert Özen Aug 8, 2026 12 min 49 views
Global Error Handling: Catching All Errors in a Single Place

We cover catching unexpected errors from anywhere in the app in a single place: escaping scattered try-catch blocks, setting up exception middleware, and returning a clean, safe response to the user.

So far we've always focused on scenarios where things go well. But in the real world, things don't go well: the database connection drops, an unexpected null appears, a conversion blows up. So what happens when these errors occur? Today we talk about catching errors that blow up in any corner of the application in a single place, and returning a proper response to the user.

Let's See the Bad Way First

The first reflex of someone new to error handling is to wrap every action method in a try-catch block:

[HttpGet("{id}")]
public async Task GetById(int id)
{
    try
    {
        var user = await _userService.GetByIdAsync(id);
        if (user is null)
        {
            return NotFound();
        }
        return Ok(user);
    }
    catch (Exception ex)
    {
        return StatusCode(500, "An error occurred.");
    }
}

This works but is pregnant with disaster. Think: your application has fifty action methods and you copy the same try-catch pattern to the top of each one. The code is full of repetition from start to finish. One day, when you want to change the shape of the error response, you'll have to fix fifty separate places one by one. If you forget one of them, that endpoint will behave differently. This is an unmaintainable path.

A Better Idea: A Single Center

What if, instead of sprinkling try-catch everywhere, we stretched a safety net at the very outside of the application? Wherever it comes from, every uncaught error would fall into this net and be handled there in a single place. This is the idea of global error handling.

Think of it like a building's central fire alarm. You don't put a separate guard in each room; instead, you set up a single system that covers the entire building. When a fire breaks out anywhere, the system kicks in, sounds the alarm, and does what's needed. On the code side, this "central system" is set up with the middleware we remember from the twelfth article.

Let's Recall Middleware

In the second article we defined middleware as a series of doors that every incoming request passes through. Each request passes through these doors in order. So if we put a middleware at the very beginning of this pipeline, that middleware wraps the rest of the request. That is, if an error blows up in any of the later doors, our middleware at the very front can catch it. Just like an outermost try-catch, but once, for the entire application.

The Modern Way: IExceptionHandler

.NET offers a clean way for this job: the IExceptionHandler interface. You write a class that implements this interface and put inside it what to do when an error occurs. It looks like this:

public class GlobalExceptionHandler : IExceptionHandler
{
    private readonly ILogger _logger;

    public GlobalExceptionHandler(ILogger logger)
    {
        _logger = logger;
    }

    public async ValueTask TryHandleAsync(
        HttpContext context,
        Exception exception,
        CancellationToken cancellationToken)
    {
        _logger.LogError(exception, "An unexpected error occurred.");

        context.Response.StatusCode = 500;

        await context.Response.WriteAsJsonAsync(new
        {
            status = 500,
            message = "An unexpected error occurred on the server."
        }, cancellationToken);

        return true;
    }
}

This class does two important jobs. First it logs the error; this is critical so you can examine the problem later. Then it returns a clean error response to the user in JSON format: status code 500 and an understandable message. The return true at the end means "I handled this error, .NET, you don't need to do anything else."

Registration and Activation

Writing the class isn't enough; you need to introduce it in Program.cs. It's handled with two lines. First you register it as a service, then you add it to the pipeline:

builder.Services.AddExceptionHandler();
builder.Services.AddProblemDetails();

These lines go before builder.Build(), in the service registration phase. Then, on the app side, you add it to the pipeline:

app.UseExceptionHandler();

Putting this line early in the pipeline is important; because, as we learned in the second article, the order of middleware determines the flow of the request. The error catcher needs to come before the other middleware where errors can occur, so that it can wrap them. Now, when an uncaught error occurs anywhere in the application, this center kicks in and your action methods stay spotless.

Why Shouldn't We Show the Raw Error to the User?

There's a critical security point here. When an error occurs, the raw error message .NET produces contains a lot of internal information: which line it blew up on, which classes were called, and sometimes even hints about your database structure. Returning this information to the outside as is means gifting the internal structure of the application to a malicious person.

So in our central catcher we separate two things: inside, we write the full detail of the error to the log (so we can see it); to the outside, we return only a general, safe message. The user sees "an error occurred on the server," but the internal details of the error never leak out. This separation both eases your debugging and keeps your application safe.

A Small Experiment

Add the GlobalExceptionHandler above to your project and register it. Then put a line that deliberately throws an error inside an action method; for example, simply write throw new Exception("test error");. When you call this endpoint, you'll now see that the application doesn't crash, but instead the clean JSON response you defined is returned. Also look at the log output: the full detail of the error is there, but not in the response returned to the user. This experiment shows both the cleanliness and the security of central error handling at once.

In the next article we'll move on to logging: we'll cover the ILogger we used in this article in depth, and talk about what structured logging with Serilog is and why recording errors properly matters so much. Those errors we logged today will become far more valuable with a good logging infrastructure.