aspnet-core-manejo-global-excepciones-global-handler

Global Exception Handling in ASP.NET Core

  • 4 min

The global exception handling is a way to capture errors in a single point of the pipeline.

There is a code smell that usually indicates something is not well structured: excessive try-catch blocks.

You open a controller and see this:

// ❌ THE ANTI-PATTERN: Paranoid defensive programming
[HttpGet("{id}")]
public IActionResult Get(int id)
{
    try
    {
        var item = _service.Get(id);
        return Ok(item);
    }
    catch (KeyNotFoundException ex)
    {
        return NotFound(ex.Message);
    }
    catch (ArgumentException ex)
    {
        return BadRequest(ex.Message);
    }
    catch (Exception ex)
    {
        _logger.LogError(ex, "Critical error");
        return StatusCode(500, "Something exploded");
    }
}
Copied!

If you have 50 endpoints, are you going to copy and paste that block 50 times? If you decide to change the format of the 500 error, you will have to edit 50 files.

In ASP.NET Core, the philosophy is different: let the exception bubble up to a global handler.

That is, we let the exception occur and “bubble up”. We will place a global safety net in the pipeline that will capture any error, log it, and return a consistent response.

Exception Middleware

ASP.NET Core has a native middleware designed for this: UseExceptionHandler.

When an exception occurs in your controller (or service, or repository), it bubbles up through the pipeline. If no one catches it, the server returns an error; with this middleware we can intercept it and generate a controlled response.

IExceptionHandler in .NET 8+

Until recently, configuring this required writing a custom middleware by hand. Since .NET 8, we have a wonderful interface: IExceptionHandler.

Let’s create a class that centralizes all our error logic.

using Microsoft.AspNetCore.Diagnostics;
using Microsoft.AspNetCore.Mvc;

public class GlobalExceptionHandler : IExceptionHandler
{
    private readonly ILogger<GlobalExceptionHandler> _logger;

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

    public async ValueTask<bool> TryHandleAsync(
        HttpContext httpContext,
        Exception exception,
        CancellationToken cancellationToken)
    {
        _logger.LogError(exception, "An unhandled error occurred: {Message}", exception.Message);

        // Define the base response
        var problemDetails = new ProblemDetails
        {
            Title = "An unexpected error occurred",
            Instance = httpContext.Request.Path
        };

        // Customize based on exception type (Pattern Matching)
        switch (exception)
        {
            case ArgumentException:
                problemDetails.Status = StatusCodes.Status400BadRequest;
                problemDetails.Title = "Validation Error";
                problemDetails.Detail = exception.Message;
                break;

            case KeyNotFoundException:
                problemDetails.Status = StatusCodes.Status404NotFound;
                problemDetails.Title = "Resource not found";
                problemDetails.Detail = exception.Message;
                break;

            case UnauthorizedAccessException:
                problemDetails.Status = StatusCodes.Status403Forbidden;
                problemDetails.Title = "Access Denied";
                break;

            default:
                problemDetails.Status = StatusCodes.Status500InternalServerError;
                problemDetails.Title = "Internal Server Error";
                problemDetails.Detail = "An unexpected error has occurred.";
                break;
        }

        httpContext.Response.StatusCode = problemDetails.Status.Value;

        // Write the response as JSON
        await httpContext.Response
            .WriteAsJsonAsync(problemDetails, cancellationToken);

        // Return true to signal: "I handled this, do not propagate the error further"
        return true;
    }
}
Copied!

What is ProblemDetails?

It is the standard Problem Details format for returning errors in HTTP APIs (RFC 9457, formerly RFC 7807). Instead of inventing a JSON {"error": "bad"}, we use a format known by tools and clients.

Register the handler in Program.cs

Now we need to tell .NET to use our class.

var builder = WebApplication.CreateBuilder(args);

// 1. Register our class in the DI
builder.Services.AddExceptionHandler<GlobalExceptionHandler>();

// 2. Add standard problem details
builder.Services.AddProblemDetails();

var app = builder.Build();

// 3. IMPORTANT! Enable the middleware
app.UseExceptionHandler();

app.MapControllers();
app.Run();
Copied!

Order matters: app.UseExceptionHandler() must be at the beginning of the pipeline, before the components whose errors we want to capture.

How the controllers look

After implementing this, we go back to our paranoid controller from the beginning and clean it up.

// ✅ GOOD: Clean, focused, and readable
[HttpGet("{id}")]
public IActionResult Get(int id)
{
    // If _service throws an exception, the GlobalHandler takes care of it.
    // We only program the "happy path".
    var item = _service.Get(id);
    return Ok(item);
}
Copied!

Notice the difference. The code is reduced by 70% and is much easier to read.

Domain Exceptions

To distinguish business errors, we can create custom exceptions in the Domain layer.

// In MyApp.Domain
public class ProductOutOfStockException : Exception
{
    public ProductOutOfStockException(int id)
        : base($"Product {id} is out of stock.") { }
}
Copied!

Then, in our GlobalExceptionHandler, we add a case for it:

case ProductOutOfStockException:
    problemDetails.Status = StatusCodes.Status409Conflict; // Conflict
    problemDetails.Title = "Insufficient Stock";
    break;
Copied!

This way, your business logic (Services/Domain) doesn’t need to know anything about HTTP or 400/500 status codes. It simply says “Something went wrong!” by throwing a semantic exception, and the API layer (the Handler) decides how to translate that into web language.