How to Create a Global Exception Handler in ASP.NET Core


When building an ASP.NET Core application, exceptions can happen anywhere in the application. A database connection may fail, an API request may contain invalid data, a resource may not exist, or an unexpected programming error may occur.

Handling every exception individually inside controllers is not a good approach. It leads to repetitive code and makes the application harder to maintain.

A better solution is to implement a global exception handler. It allows you to catch unhandled exceptions in one central location and return a consistent response to the client.

In this article, we will learn how to create a global exception handler in ASP.NET Core using middleware and the built-in exception-handling capabilities.

Why Do We Need Global Exception Handling?

Consider a simple controller:

[HttpGet("{id}")]
public IActionResult GetUser(int id)
{
    try
    {
        var user = _userService.GetUser(id);

        if (user == null)
            return NotFound();

        return Ok(user);
    }
    catch (Exception ex)
    {
        return StatusCode(500, "Something went wrong");
    }
}

If your application has dozens of endpoints, repeating this try-catch pattern everywhere becomes difficult to manage.

A global exception handler provides several advantages:

  • Centralized exception handling
  • Consistent API responses
  • Cleaner controllers
  • Easier logging
  • Better separation of concerns
  • Improved maintainability
  • Better production error handling

Instead of handling unexpected exceptions in every controller, we can handle them once at the application level.

Approach 1: Create Global Exception Middleware

One of the most common approaches is creating custom middleware.

Middleware can inspect every HTTP request and response that passes through the ASP.NET Core pipeline. We can use this behavior to catch unhandled exceptions.

Step 1: Create an Exception Middleware

Create a class called GlobalExceptionMiddleware.

using System.Net;
using System.Text.Json;

public class GlobalExceptionMiddleware
{
    private readonly RequestDelegate _next;
    private readonly ILogger<GlobalExceptionMiddleware> _logger;

    public GlobalExceptionMiddleware(
        RequestDelegate next,
        ILogger<GlobalExceptionMiddleware> logger)
    {
        _next = next;
        _logger = logger;
    }

    public async Task InvokeAsync(HttpContext context)
    {
        try
        {
            await _next(context);
        }
        catch (Exception ex)
        {
            _logger.LogError(ex, "An unhandled exception occurred.");

            await HandleExceptionAsync(context, ex);
        }
    }

    private static async Task HandleExceptionAsync(
        HttpContext context,
        Exception exception)
    {
        context.Response.ContentType = "application/json";

        context.Response.StatusCode = exception switch
        {
            KeyNotFoundException => StatusCodes.Status404NotFound,
            ArgumentException => StatusCodes.Status400BadRequest,
            UnauthorizedAccessException => StatusCodes.Status401Unauthorized,
            _ => StatusCodes.Status500InternalServerError
        };

        var response = new
        {
            statusCode = context.Response.StatusCode,
            message = exception switch
            {
                KeyNotFoundException => "The requested resource was not found.",
                ArgumentException => exception.Message,
                UnauthorizedAccessException => "You are not authorized to perform this operation.",
                _ => "An unexpected error occurred."
            }
        };

        await context.Response.WriteAsync(
            JsonSerializer.Serialize(response));
    }
}

The middleware surrounds the next component in the pipeline with a try-catch. If an exception occurs anywhere downstream, it is caught by this middleware.

Step 2: Register the Middleware

Now register the middleware in Program.cs.

For a modern ASP.NET Core application:

var builder = WebApplication.CreateBuilder(args);

builder.Services.AddControllers();

var app = builder.Build();

app.UseMiddleware<GlobalExceptionMiddleware>();

app.UseHttpsRedirection();

app.UseAuthorization();

app.MapControllers();

app.Run();

The order of middleware is important.

The exception middleware should be placed early in the pipeline so that it can catch exceptions thrown by middleware and endpoints that execute after it.

Step 3: Test the Exception Handler

Create an API endpoint that deliberately throws an exception:

[HttpGet("test")]
public IActionResult Test()
{
    throw new Exception("This is a test exception.");
}

When you call:

GET /api/test

instead of receiving an unhandled exception, the global middleware will catch it and return a controlled response.

For example:

{
  "statusCode": 500,
  "message": "An unexpected error occurred."
}

This gives your API a consistent error format.

Using Custom Exceptions

In real-world applications, it is often useful to create custom exceptions.

For example:

public class NotFoundException : Exception
{
    public NotFoundException(string message)
        : base(message)
    {
    }
}

You can then throw it from your service:

public User GetUser(int id)
{
    var user = _repository.GetUser(id);

    if (user == null)
        throw new NotFoundException("User was not found.");

    return user;
}

The global exception handler can recognize this exception:

context.Response.StatusCode = exception switch
{
    NotFoundException => StatusCodes.Status404NotFound,
    ArgumentException => StatusCodes.Status400BadRequest,
    _ => StatusCodes.Status500InternalServerError
};

This makes your business logic easier to understand because the service can communicate what went wrong without knowing how the HTTP response should be generated.

Approach 2: Use ASP.NET Core's Built-In Exception Handler

Modern ASP.NET Core versions provide built-in exception-handling support through UseExceptionHandler.

A simple configuration looks like this:

var builder = WebApplication.CreateBuilder(args);

builder.Services.AddControllers();

var app = builder.Build();

app.UseExceptionHandler("/error");

app.UseHttpsRedirection();

app.UseAuthorization();

app.MapControllers();

app.Run();

You can create an error endpoint:

[ApiController]
public class ErrorController : ControllerBase
{
    [Route("/error")]
    public IActionResult Error()
    {
        return Problem(
            statusCode: StatusCodes.Status500InternalServerError,
            title: "An unexpected error occurred.");
    }
}

This approach is simpler because ASP.NET Core manages much of the exception-handling pipeline for you.

Using ProblemDetails

For REST APIs, returning a standard error format is generally preferable to creating a different JSON structure for every project.

ASP.NET Core supports the Problem Details format.

You can register it with:

builder.Services.AddProblemDetails();

Then configure exception handling:

var builder = WebApplication.CreateBuilder(args);

builder.Services.AddControllers();
builder.Services.AddProblemDetails();

var app = builder.Build();

app.UseExceptionHandler();

app.MapControllers();

app.Run();

A typical error response can look like:

{
  "type": "https://example.com/errors/internal-server-error",
  "title": "An unexpected error occurred.",
  "status": 500,
  "detail": "An unexpected error occurred."
}

Using ProblemDetails can make your API responses easier for clients to consume consistently.

Don't Expose Sensitive Exception Details

One of the most important rules when implementing global exception handling is not to expose internal exception details in production.

Avoid returning this:

{
  "message": "SqlException: Login failed for user 'sa'...",
  "stackTrace": "at MyApplication.Data.UserRepository..."
}

Such information can reveal:

  • Database details
  • File paths
  • Internal class names
  • SQL queries
  • Stack traces
  • Infrastructure information
  • Security-sensitive information

Instead, return a generic message to the client:

{
  "statusCode": 500,
  "message": "An unexpected error occurred."
}

The complete exception should be recorded in your server-side logs.

Logging Exceptions

A global exception handler is also an excellent place to log unexpected errors.

For example:

_logger.LogError(
    exception,
    "Unhandled exception occurred while processing request {Path}",
    context.Request.Path);

The log can then be analyzed using your logging infrastructure.

For production applications, you might use structured logging and monitoring systems to track:

  • Exception type
  • Request path
  • HTTP method
  • Correlation ID
  • User or tenant context where appropriate
  • Timestamp
  • Server information

Be careful not to log passwords, access tokens, sensitive personal information, or other secrets.

Adding a Correlation ID

A correlation ID can be useful when debugging distributed applications.

For example:

var correlationId = context.TraceIdentifier;

You can include it in your response:

var response = new
{
    statusCode = context.Response.StatusCode,
    message = "An unexpected error occurred.",
    traceId = context.TraceIdentifier
};

The client receives:

{
  "statusCode": 500,
  "message": "An unexpected error occurred.",
  "traceId": "00-abc123..."
}

The user can provide this ID to support staff, who can then find the corresponding error in the server logs.

Recommended Exception Mapping

A typical API can map exceptions to HTTP status codes like this:

Exception HTTP Status
ArgumentException 400 Bad Request
UnauthorizedAccessException 401 Unauthorized
Custom ForbiddenException 403 Forbidden
NotFoundException 404 Not Found
ConflictException 409 Conflict
Unexpected Exception 500 Internal Server Error

However, don't blindly map every .NET exception to an HTTP status code. The mapping should reflect the meaning of the error in your application's domain.

Middleware vs UseExceptionHandler

Both approaches are useful, but they serve slightly different purposes.

Custom Middleware

Custom middleware is useful when you need complete control over:

  • Exception-to-status-code mapping
  • Response format
  • Logging
  • Correlation IDs
  • Custom business exceptions
  • Additional diagnostic information

Built-In Exception Handler

UseExceptionHandler is preferable when you want a simpler, framework-supported solution with less custom infrastructure.

For many modern ASP.NET Core APIs, combining built-in exception handling with ProblemDetails is a clean approach.

Best Practices

When implementing global exception handling in ASP.NET Core, keep these practices in mind:

1. Handle exceptions centrally

Avoid putting unnecessary try-catch blocks inside every controller.

2. Log unexpected exceptions

Always make sure unexpected errors are available in server-side logs.

3. Don't expose stack traces in production

Detailed exception information should generally stay on the server.

4. Use meaningful HTTP status codes

Return 404 for missing resources, 400 for invalid requests, 401 for authentication failures, and so on.

5. Use a consistent response format

ProblemDetails is a good option for APIs.

6. Create custom exceptions for business scenarios

Custom exceptions can make domain-specific errors easier to identify and handle.

7. Test your exception handling

Test expected and unexpected failures, including database errors, validation failures, authorization failures, and unknown exceptions.

Conclusion

Global exception handling is an important part of building reliable ASP.NET Core applications. Instead of scattering try-catch blocks throughout controllers and services, you can centralize unexpected error handling in middleware or use ASP.NET Core's built-in exception-handling functionality.

For a simple application, UseExceptionHandler with ProblemDetails can provide an effective solution. For applications requiring customized error mapping, logging, correlation IDs, or domain-specific exceptions, custom middleware gives you more control.

The most important principle is to handle errors consistently, log useful diagnostic information, and avoid exposing sensitive internal details to API consumers.

A well-designed global exception handler makes your ASP.NET Core API cleaner, easier to maintain, safer in production, and easier to troubleshoot.

0 Comments Report