Updated on

Global exception handling in ASP.NET Core Web API lets us catch every unhandled exception in one place and turn it into a proper error response, so our actions don’t need a try-catch block each.

The exception-handling features help us deal with unforeseen errors that could appear in our code. To handle exceptions we can use the try-catch block in our code as well as finally keyword to clean up resources afterward.

To download the source code for this article, you can visit our GitHub repository.

Even though there is nothing wrong with the try-catch blocks in our Actions in Web API project, we can extract all the exception-handling logic into a single centralized place. Doing that makes our actions more readable and the error-handling process more maintainable. If we want to make our actions more readable and maintainable, we can implement Action Filters. We won’t talk about action filters in this article but we strongly recommend reading our post Action Filters in .NET Core.

In this article, we are going to handle errors by using a try-catch block first and then rewrite our code by using built-in middleware, our custom middleware and the IExceptionHandler interface for global error handling to demonstrate the benefits of this approach. We are going to use an ASP.NET Core Web API project to explain these features and if you want to learn more about it (which we strongly recommend), you can read our ASP.NET Core Web API Tutorial.


VIDEO: Global Error Handling in ASP.NET Core Web API video.


What Is Global Exception Handling in ASP.NET Core?

Global exception handling is one place in the request pipeline that catches every exception our actions don’t handle. It logs the exception and turns it into an error response, so our actions stay free of try-catch blocks and every client gets the same error format.

ASP.NET Core gives us that place out of the box. app.UseExceptionHandler() adds the built-in exception handler middleware, which catches the exception. builder.Services.AddProblemDetails() gives the middleware its default answer: a 500 response in the problem details format, the standard JSON shape for HTTP API errors.

When a specific exception deserves a different answer, we can implement the IExceptionHandler interface, which came with .NET 8. The middleware asks our handlers in the order we register them, and the first one that handles the exception writes the response.

Either way, the client gets a short and safe response, while the stack trace stays in our log.

Let’s start.

Error Handling With Try-Catch Block

To start with this example, let’s create a new Web API project with controllers:

dotnet new webapi --use-controllers --no-openapi -n GlobalErrorHandling

The template adds a WeatherForecast controller and class. We won’t use them, so we can delete both.

Our data is a simple list of students. Let’s create a Models folder and add a Student record inside it:

namespace GlobalErrorHandling.Models;

public record Student(int Id, string Name);

Then, let’s add a DataManager class to the same folder:

namespace GlobalErrorHandling.Models;

public static class DataManager
{
    public static List<Student> GetAllStudents() =>
    [
        new(1, "John Doe"),
        new(2, "Jane Smith"),
        new(3, "Mike Johnson")
    ];
}

It is a common practice to include the log messages while handling errors, therefore we are going to use the built-in ILogger service. It logs all the messages to the console, but you can send them to a file by adding a logging provider. For more information about how to use NLog in .NET Core, you can visit Logging with NLog.

Now, let’s create a ValuesController class in the Controllers folder, with a single Get() method that returns a result and logs some messages:

using GlobalErrorHandling.Models;
using Microsoft.AspNetCore.Mvc;

namespace GlobalErrorHandling.Controllers;

[Route("api/[controller]")]
[ApiController]
public class ValuesController(ILogger<ValuesController> logger) : ControllerBase
{
    [HttpGet]
    public IActionResult Get()
    {
        try
        {
            logger.LogInformation("Fetching all the Students from the storage");
            var students = DataManager.GetAllStudents(); //simulation for the data base access
            logger.LogInformation("Returning {Count} students.", students.Count);
            return Ok(students);
        }
        catch (Exception ex)
        {
            logger.LogError(ex, "Something went wrong");
            return StatusCode(500, "Internal server error");
        }
    }
}

When we send a request at this endpoint, we will get this result:

[
  {
    "id": 1,
    "name": "John Doe"
  },
  {
    "id": 2,
    "name": "Jane Smith"
  },
  {
    "id": 3,
    "name": "Mike Johnson"
  }
]

And the log messages:

info: GlobalErrorHandling.Controllers.ValuesController[0]
      Fetching all the Students from the storage
info: GlobalErrorHandling.Controllers.ValuesController[0]
      Returning 3 students.

You’ll also see a warning about the HTTPS port above them, because the app runs over plain HTTP. We leave it out here.

We see that everything is working as expected.

Now let’s modify our code, right below the GetAllStudents() method call, to force an exception:

throw new Exception("Exception while fetching all the students from the storage.");

The compiler warns us that the code after the throw can’t be reached. That’s expected, since we force the exception on purpose.

Now, if we send a request:

HTTP/1.1 500 Internal Server Error
Content-Type: text/plain; charset=utf-8

Internal server error

And the log messages:

info: GlobalErrorHandling.Controllers.ValuesController[0]
      Fetching all the Students from the storage
fail: GlobalErrorHandling.Controllers.ValuesController[0]
      Something went wrong
      System.Exception: Exception while fetching all the students from the storage.
         at GlobalErrorHandling.Controllers.ValuesController.Get() in ...\Controllers\ValuesController.cs:line 17

So, this works just fine. But the downside of this approach is that we need to repeat our try-catch blocks in all the actions in which we want to catch unhandled exceptions. Well, there is a better approach to do that.

Handling Errors Globally With the Built-In Middleware

The UseExceptionHandler() middleware is a built-in middleware that we can use to handle exceptions in our ASP.NET Core Web API application. So, let’s dive into the code to see this middleware in action.

We don’t need a custom class for the details of our error message anymore. ASP.NET Core can write error responses in the problem details format, and our article on ProblemDetails in ASP.NET Core explains every field of it.

To continue, let’s modify the Program class:

var builder = WebApplication.CreateBuilder(args);

builder.Services.AddControllers();

builder.Services.AddProblemDetails();

var app = builder.Build();

app.UseExceptionHandler();

app.UseHttpsRedirection();

app.UseAuthorization();

app.MapControllers();

app.Run();

In the code above, we’ve registered the problem details service with AddProblemDetails() and the exception handler middleware with UseExceptionHandler(). When an exception reaches the middleware, it populates the status code and the content type of our response, logs the error message and finally returns the response in the problem details format.

Do we need both lines? Yes. Without AddProblemDetails(), the middleware has nothing to answer with, and the app refuses to start:

System.InvalidOperationException: An error occurred when configuring the exception handler middleware. Either the 'ExceptionHandlingPath' or the 'ExceptionHandler' property must be set in 'UseExceptionHandler()'.
...

We also don’t call UseDeveloperExceptionPage() anymore. In Development, ASP.NET Core adds the developer exception page for us, but our exception handler catches the exception before the page can see it. So, we get the same response in Development and in Production, and the details stay in the log.

Finally, let’s remove the try-catch block from our code:

[HttpGet]
public IActionResult Get()
{
    logger.LogInformation("Fetching all the Students from the storage");

    var students = DataManager.GetAllStudents(); //simulation for the data base access

    throw new Exception("Exception while fetching all the students from the storage.");

    logger.LogInformation("Returning {Count} students.", students.Count);

    return Ok(students);
}

And there you go. Our action method is much cleaner now and what’s more important we can reuse this functionality to write more readable actions in the future.

So let’s inspect the result:

HTTP/1.1 500 Internal Server Error
Content-Type: application/problem+json; charset=utf-8

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

And the log messages, with the stack trace cut short:

info: GlobalErrorHandling.Controllers.ValuesController[0]
      Fetching all the Students from the storage
fail: Microsoft.AspNetCore.Diagnostics.ExceptionHandlerMiddleware[1]
      An unhandled exception has occurred while executing the request.
      System.Exception: Exception while fetching all the students from the storage.
         at GlobalErrorHandling.Controllers.ValuesController.Get() in ...\Controllers\ValuesController.cs:line 17
         ...

The client gets no stack trace and no exception message, and we still have both in the log. When that logged exception includes async and lambda frames that make the real error hard to spot, the stack trace cleaner strips the noise and points at the line in your code.

Excellent.

Now, we are going to use custom middleware for global error handling.

Handling Errors Globally With the Custom Middleware

Let’s create a new folder named CustomExceptionMiddleware and a class ExceptionMiddleware.cs inside it.

We are going to modify that class:

namespace GlobalErrorHandling.CustomExceptionMiddleware;

public class ExceptionMiddleware(
    RequestDelegate next,
    ILogger<ExceptionMiddleware> logger,
    IProblemDetailsService problemDetailsService)
{
    public async Task InvokeAsync(HttpContext httpContext)
    {
        try
        {
            await next(httpContext);
        }
        catch (Exception ex)
        {
            logger.LogError(ex, "Something went wrong");
            await HandleExceptionAsync(httpContext, ex);
        }
    }

    private async Task HandleExceptionAsync(HttpContext context, Exception exception)
    {
        context.Response.StatusCode = StatusCodes.Status500InternalServerError;

        await problemDetailsService.WriteAsync(new ProblemDetailsContext
        {
            HttpContext = context,
            Exception = exception,
            ProblemDetails = { Detail = "Internal Server Error from the custom middleware." }
        });
    }
}

The first thing we need to do is to inject our ILogger, the IProblemDetailsService and the RequestDelegate through the primary constructor. The next parameter of RequestDelegate type is a function delegate that can process our HTTP requests.

After the registration process, we create the InvokeAsync() method. RequestDelegate can’t process requests without it.

If everything goes well, the next delegate should process the request, and the Get action from our controller should generate a successful response. But if a request is unsuccessful (and it is, because we are forcing an exception), our middleware will trigger the catch block and call the HandleExceptionAsync method.

In that method, we just set up the response status code and let the problem details service write the response, content type included. That way, our middleware answers in the same format as the built-in one.

Now, let’s create a new folder Extensions and a new static class ExceptionMiddlewareExtensions inside it:

using GlobalErrorHandling.CustomExceptionMiddleware;

namespace GlobalErrorHandling.Extensions;

public static class ExceptionMiddlewareExtensions
{
    public static void ConfigureCustomExceptionMiddleware(this IApplicationBuilder app)
    {
        app.UseMiddleware<ExceptionMiddleware>();
    }
}

Finally, let’s use this method in the Program class:

using GlobalErrorHandling.Extensions;

var builder = WebApplication.CreateBuilder(args);

builder.Services.AddControllers();

builder.Services.AddProblemDetails();

var app = builder.Build();

//app.UseExceptionHandler();
app.ConfigureCustomExceptionMiddleware();

app.UseHttpsRedirection();

app.UseAuthorization();

app.MapControllers();

app.Run();

We keep AddProblemDetails(), because our middleware uses the problem details service.

Now let’s inspect the result again:

HTTP/1.1 500 Internal Server Error
Content-Type: application/problem+json; charset=utf-8

{
  "type": "https://tools.ietf.org/html/rfc9110#section-15.6.1",
  "title": "An error occurred while processing your request.",
  "status": 500,
  "detail": "Internal Server Error from the custom middleware.",
  "traceId": "00-ba0934a1519b04e39aed3cadceb87ee2-54ca5c8d13e5b9ef-00"
}

There we go. Our custom middleware is implemented in a couple of steps.

Customizing Error Messages

If you want, you can always customize your error messages from the error handler. There are different ways of doing that, but we are going to show you the basic two ways.

First of all, we can assume that the AccessViolationException is thrown from our action:

[HttpGet]
public IActionResult Get()
{
    logger.LogInformation("Fetching all the Students from the storage");

    var students = DataManager.GetAllStudents(); //simulation for the data base access

    throw new AccessViolationException("Violation Exception while accessing the resource.");

    logger.LogInformation("Returning {Count} students.", students.Count);

    return Ok(students);
}

Now, what we can do is modify the InvokeAsync method inside the ExceptionMiddleware.cs class by adding a specific exception checking in the additional catch block:

public async Task InvokeAsync(HttpContext httpContext)
{
    try
    {
        await next(httpContext);
    }
    catch (AccessViolationException avEx)
    {
        logger.LogError(avEx, "A new violation exception has been thrown");
        await HandleExceptionAsync(httpContext, avEx);
    }
    catch (Exception ex)
    {
        logger.LogError(ex, "Something went wrong");
        await HandleExceptionAsync(httpContext, ex);
    }
}

Now if we send another request with Postman, we are going to see in the log that the AccessViolationException message is logged. Of course, our specific exception check must be placed before the global catch block.

With this solution, we are logging specific messages for the specific exceptions, and that can help us, as developers, a lot when we publish our application. But if we want to send a different message for a specific error, we can also modify the HandleExceptionAsync method in the same class:

private async Task HandleExceptionAsync(HttpContext context, Exception exception)
{
    context.Response.StatusCode = StatusCodes.Status500InternalServerError;

    var message = exception switch
    {
        AccessViolationException => "Access violation error from the custom middleware",
        _ => "Internal Server Error from the custom middleware."
    };

    await problemDetailsService.WriteAsync(new ProblemDetailsContext
    {
        HttpContext = context,
        Exception = exception,
        ProblemDetails = { Detail = message }
    });
}

Here, we are using a switch expression pattern matching to check the type of our exception and assign the right message to the message variable. Then, we just use that variable in the WriteAsync method.

Now if we test this, we will get a log message with the Access violation message, and our response will have a new message as well:

{
  "type": "https://tools.ietf.org/html/rfc9110#section-15.6.1",
  "title": "An error occurred while processing your request.",
  "status": 500,
  "detail": "Access violation error from the custom middleware",
  "traceId": "00-20da040018b96b1d84793ed7a8595910-06c31a91e2969fd5-00"
}

One thing to mention here. We are using the 500 status code for all the responses from the exception middleware, and that is something we believe should be done. After all, we are handling exceptions and these exceptions should be marked with a 500 status code. But this doesn’t have to be the case all the time. For example, if you have a service layer and you want to propagate responses from the service methods as custom exceptions and catch them inside the global exception handler, you may want to choose a more appropriate status code for the response. Service methods can also return their errors as values instead of throwing them, and our article on the Result pattern in .NET Web API shows that technique. It depends on your project organization.

Using the IExceptionHandler Interface from .NET 8

IExceptionHandler is an interface that we can use to handle exceptions in ASP.NET Core applications. It defines an interface that we can implement to handle exceptions globally. This allows us to write custom logic for handling individual exceptions or groups of exceptions based on their type, in turn providing tailored responses, error messages as well as logging.

Let’s use it to customize our access violation message without the custom middleware. First, we create an ExceptionHandlers folder and an AccessViolationExceptionHandler class inside it:

using Microsoft.AspNetCore.Diagnostics;

namespace GlobalErrorHandling.ExceptionHandlers;

public class AccessViolationExceptionHandler(
    IProblemDetailsService problemDetailsService,
    ILogger<AccessViolationExceptionHandler> logger) : IExceptionHandler
{
    public async ValueTask<bool> TryHandleAsync(
        HttpContext httpContext,
        Exception exception,
        CancellationToken cancellationToken)
    {
        if (exception is not AccessViolationException)
            return false;

        logger.LogError(exception, "A new violation exception has been thrown");

        httpContext.Response.StatusCode = StatusCodes.Status500InternalServerError;

        return await problemDetailsService.TryWriteAsync(new ProblemDetailsContext
        {
            HttpContext = httpContext,
            Exception = exception,
            ProblemDetails = { Detail = "Access violation error from the exception handler" }
        });
    }
}

The TryHandleAsync() method first checks the type of our exception. For anything that isn’t an AccessViolationException, it returns false, which tells the middleware to keep looking.

For our exception, it logs the specific message, sets the status code and writes the response through the problem details service. Finally, it returns the result of TryWriteAsync(), which is true once the response is written.

Now, let’s register the handler in the Program class and switch back to the built-in middleware:

using GlobalErrorHandling.ExceptionHandlers;

var builder = WebApplication.CreateBuilder(args);

builder.Services.AddControllers();

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

var app = builder.Build();

app.UseExceptionHandler();

app.UseHttpsRedirection();

app.UseAuthorization();

app.MapControllers();

app.Run();

AddExceptionHandler<T>() registers our handler as a singleton, so we should inject only services that are safe to share, such as loggers.

Let’s send our request again:

HTTP/1.1 500 Internal Server Error
Content-Type: application/problem+json

{
  "type": "https://tools.ietf.org/html/rfc9110#section-15.6.1",
  "title": "An error occurred while processing your request.",
  "status": 500,
  "detail": "Access violation error from the exception handler",
  "traceId": "00-60945a4a058c68cf49294f894d90a3d5-da26a20721b936e6-00"
}

We get the same response in Production. This time, the only error in the log comes from our handler:

fail: GlobalErrorHandling.ExceptionHandlers.AccessViolationExceptionHandler[0]
      A new violation exception has been thrown
      System.AccessViolationException: Violation Exception while accessing the resource.
         at GlobalErrorHandling.Controllers.ValuesController.Get() in ...\Controllers\ValuesController.cs:line 17
         ...

The middleware didn’t log the exception, and that’s new in .NET 10. Microsoft’s breaking change notice says it directly: “Starting in .NET 10, if IExceptionHandler.TryHandleAsync returns true, then exception diagnostics are no longer recorded by default.” That’s why our handler logs the exception itself. On .NET 8 and 9, the middleware logs every exception as an error, handled or not.

Any other exception still gets the generic 500 from the built-in middleware section. Our handler returns false for it, so the middleware writes the default response and logs the exception as an error.

We can register more handlers, one for each exception that needs its own response. Microsoft Learn’s page on handling errors in ASP.NET Core puts the rule in one sentence: “Multiple implementations can be added, and they’re called in the order registered.” So, we register the specific handlers first and anything general last.

A handler is also the place to choose a status code other than 500, for example a 503 when a service we depend on times out.

Since we already have an article on this topic, feel free to read our IExceptionHandler article. You will find all the information you need to use this interface, which will improve the handling logic as well.

Do We Still Need Custom Exception Middleware?

No, not for exception handling. Before .NET 8, a custom middleware with a try-catch around the next delegate was a common way to control the error response, and many older projects still carry one. Today, the built-in exception handler middleware does the same job, and turning it on takes two lines: AddProblemDetails() and UseExceptionHandler(). IExceptionHandler classes customize the response for the exceptions that need it.

The built-in middleware also handles cases a hand-written one usually misses. It adds headers that stop caches from storing the error response. It also checks whether the response has already started. If it has, nobody can change the status code anymore, so the middleware logs a warning and lets the server end the connection.

A custom middleware still makes sense for work that isn’t exception handling, such as timing requests or adding a header to every response.

Our custom middleware sends no such headers, and it can’t handle a started response either. Its catch block throws:

System.InvalidOperationException: StatusCode cannot be set because the response has already started.

We tried it with an action that starts writing its response and then throws. In the same situation, the built-in middleware logged “The response has already started, the error handler will not be executed.” and let the server end the connection.

Conclusion

That was awesome.

We have learned how to handle errors in a more sophisticated way and cleaner as well. The code is much more readable and our exception handling logic is now reusable for the entire project.

If you want to shape the error responses further, for example to show the exception message in Development only, our ProblemDetails article is the next step.

Thank you for reading this article. We hope you have learned new useful things.

Tested with .NET 10.0.12 (SDK 10.0.401), Microsoft.AspNetCore.Mvc.Testing 10.0.12 and xUnit v3 4.0.1.