Global Exception Handling in .NET – Why You Can’t Ignore It

Global Exception Handling in .NET - Why You Can’t Ignore It

You’ve probably seen it…

An unhandled exception in production, no logs, a vague 500 error, and a confused team trying to understand what just went wrong.

The truth is: exception handling is often treated as an afterthought… until things break.

But global exception handling isn’t just a nice-to-have, it’s a foundation for building reliable APIs.

Of course, when we know the failure scenario, the best practice is to avoid exceptions entirely by using techniques like the Result Pattern, I’ve written about this in detail here.

However, exceptions are still inevitable. Network issues, null references, deserialization problems, and external service failures, these are real and will happen.

So the question is: when they do, will your app handle them gracefully?

Let’s dive into why global exception handling matters and how to implement it right in .NET.

What Happens Without Global Exception Handling

Let’s say you’re using Minimal APIs to create a note. You’re following modern practices: Result<T> for known failures and ISender from MediatR:

Here’s what the code might look like without global exception handling:

C#
				app.MapPost("/notes", async (
    CreateNoteRequest request,
    ISender sender,
    ILogger<Program> logger) =>
{
    try
    {
        var result = await sender.Send(request);

        if (result.IsSuccess)
        {
            return Results.Created($"/notes/{result.Value.Id}", result.Value);
        }

        return Results.BadRequest(result.Error);
    }
    catch (Exception ex)
    {
        logger.LogError(ex, "Unexpected error while creating a note.");
        return Results.Problem("An unexpected error occurred.");
    }
});
			

This works. It catches unhandled exceptions, logs the error, and returns a generic 500 response.

But now imagine doing this in every controller, in every endpoint. The same try/catch logic is repeated or what happens if someone forgets to wrap a new endpoint in a try/catch…

This is not scalable, hard to maintain, and makes your codebase fragile, and that’s why you can’t ignore it.

The “Old” Way To Apply Global Exception Handling

Before the introduction of IExceptionHandler in .NET 8, global exception handling in ASP.NET Core was typically implemented in three main ways:

  • Using Request Delegates
  • By Convention
  • The Middleware using factory

 

The way the developers liked the most, me included, was including a factory with IMiddleware:

C#
				public class FactoryMiddleware(ILogger<FactoryMiddleware> logger) : IMiddleware
{
    public async Task InvokeAsync(HttpContext context, RequestDelegate next)
    {
        try
        {
            await next(context);
        }
        catch (Exception ex)
        {
            logger.LogError(ex, "Unhandled exception occurred: {Message}.", ex.Message);

            var problem = new ProblemDetails
            {
                Title = "An unexpected error occurred.",
                Status = StatusCodes.Status500InternalServerError,
                Detail = "Please contact support if the problem persists."
            };

            context.Response.StatusCode = problem.Status.Value;

            await context.Response.WriteAsJsonAsync(problem);
        }
    }
}
			
C#
				builder.Services.AddTransient<FactoryMiddleware>();

			
C#
				app.UseMiddleware<FactoryMiddleware>();
			

One of the key advantages of implementing IMiddleware is how predictable and structured it becomes. This opens the door for generic registration using reflection, ensuring you never forget to wire up a middleware manually.

The “New” Way to Handle Exceptions Globally (.NET 8)

.NET 8 finally gave us a native and clean solution to handle exceptions globally: IExceptionHandler.

It removes the need for create a middleware, try/catch in every endpoint, and weird workarounds just to return a consistent error response.

A basic example looks like this:

C#
				internal sealed class GlobalExceptionHandler(ILogger<GlobalExceptionHandler> logger) : IExceptionHandler
{
    public async ValueTask<bool> TryHandleAsync(
        HttpContext context,
        Exception exception,
        CancellationToken cancellationToken)
    {
        logger.LogError(exception, "Exception occurred: {Message}", exception.Message);

        var problem = new ProblemDetails
        {
            Title = "An unexpected error occurred.",
            Status = StatusCodes.Status500InternalServerError,
            Detail = "Please contact support if the problem persists."
        };

        context.Response.StatusCode = problem.Status.Value;

        await context.Response.WriteAsJsonAsync(problem, cancellationToken);

        return true;
    }
}

			

The idea is simple: any unhandled exception will hit this handler, and you decide how to log it and what to return.

To plug it in:

C#
				builder.Services.AddExceptionHandler<GlobalExceptionHandler>();
builder.Services.AddProblemDetails();
			
C#
				app.UseExceptionHandler();
			

Some people like to return the result object itself:

C#
				internal sealed class GlobalExceptionHandler(ILogger<GlobalExceptionHandler> logger) : IExceptionHandler
{
	public async ValueTask<bool> TryHandleAsync(
		HttpContext context,
		Exception exception,
		CancellationToken cancellationToken)
	{
		logger.LogError(exception, "Exception occurred: {Message}", exception.Message);

		var result = Result.Failure(
                            Error.Failure(StatusCodes.Status500InternalServerError, 
                                         "An unexpected error occurred."));

		context.Response.StatusCode = StatusCodes.Status500InternalServerError;

		await context.Response.WriteAsJsonAsync(result, cancellationToken);

		return true;
	}
}

			

That’s it.

You’re not limited to a single IExceptionHandler. You can register multiple, and .NET will call them in order.

This is useful when you throw custom exceptions, not using only bad request:

C#
				public sealed class UnauthorizedExceptionHandler(ILogger<UnauthorizedExceptionHandler> logger) : IExceptionHandler
{
    public async ValueTask<bool> TryHandleAsync(
        HttpContext context,
        Exception exception,
        CancellationToken cancellationToken)
    {
        if (exception is not UnauthorizedAccessException)
        {
            return false;
        }

        logger.LogWarning(exception, "Unauthorized access attempt: {Message}", exception.Message);

        var problem = new ProblemDetails
        {
            Title = "Unauthorized",
            Status = StatusCodes.Status401Unauthorized,
            Detail = "You are not authorized to access this resource."
        };

        context.Response.StatusCode = problem.Status.Value;
        await context.Response.WriteAsJsonAsync(problem, cancellationToken);

        return true;
    }
}

			

Register it:

C#
				builder.Services.AddExceptionHandler<UnauthorizedExceptionHandler>();
builder.Services.AddExceptionHandler<GlobalExceptionHandler>();
builder.Services.AddProblemDetails();
			
C#
				app.UseExceptionHandler();
			

In this case, it will be executed in this order: the UnauthorizedExceptionHandler executes first trying to handle the exception, the next would be the execution of the GlobalExceptionHandler.

Another common way is to use an extension method and call it in your program.cs

Conclusion

Global exception handling isn’t just a nice-to-have, it’s a must in any serious API.

With IExceptionHandler, .NET gives us a clean, extensible, and testable way to deal with errors. You can keep your endpoints focused on business logic while handling failures in a centralized, consistent manner.

And remember: when we know the failure scenario, the best practice is to avoid exceptions entirely by using techniques like the Result Pattern, I’ve written about this in detail here.

Don’t ignore this. Your API deserves better.

Thank you for reading.

See you next time!

Share the Post:
plugins premium WordPress