Core API Global Error Handling

Without proper error handling, an unhandled exception in the BookStore API will either crash the request silently or expose a full stack trace to the client — both are unacceptable in production. Global error handling catches all unhandled exceptions from any part of the application and returns a clean, consistent error response to the client.

The Problem Without Global Error Handling

// Without error handling, if the database is down:
GET /api/books

Response: 500 Internal Server Error
{
  "type": "https://...",
  "title": "An unhandled exception occurred.",
  "detail": "System.Data.SqlClient.SqlException: Cannot open database 'BookStoreDB'...
             at Microsoft.EntityFrameworkCore.Storage.Internal.RelationalCommand...
             at BookStoreAPI.Repositories.BookRepository.GetAllAsync()..." ← exposes internals!
}

This exposes internal implementation details to the client — a security risk and a poor user experience.

The Goal: Consistent Error Responses

Every error response from the BookStore API should follow this structure:

{
  "statusCode": 500,
  "message": "An unexpected error occurred. Please try again later.",
  "details": "Cannot open database 'BookStoreDB'" // only in Development mode
}

Creating the Error Response Model

// Models/ErrorResponse.cs
namespace BookStoreAPI.Models
{
    public class ErrorResponse
    {
        public int StatusCode { get; set; }
        public string Message { get; set; } = string.Empty;
        public string? Details { get; set; }    // null in Production
    }
}

Creating the Exception Middleware

// Middleware/ExceptionHandlingMiddleware.cs
using BookStoreAPI.Models;
using System.Net;
using System.Text.Json;

namespace BookStoreAPI.Middleware
{
    public class ExceptionHandlingMiddleware
    {
        private readonly RequestDelegate _next;
        private readonly ILogger<ExceptionHandlingMiddleware> _logger;
        private readonly IWebHostEnvironment _env;

        public ExceptionHandlingMiddleware(
            RequestDelegate next,
            ILogger<ExceptionHandlingMiddleware> logger,
            IWebHostEnvironment env)
        {
            _next = next;
            _logger = logger;
            _env = env;
        }

        public async Task InvokeAsync(HttpContext context)
        {
            try
            {
                await _next(context);   // Run the rest of the pipeline
            }
            catch (Exception ex)
            {
                _logger.LogError(ex, "An unhandled exception occurred: {Message}", ex.Message);
                await HandleExceptionAsync(context, ex);
            }
        }

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

            var response = new ErrorResponse();

            switch (ex)
            {
                case KeyNotFoundException:
                    context.Response.StatusCode = (int)HttpStatusCode.NotFound;
                    response.StatusCode = 404;
                    response.Message = ex.Message;
                    break;

                case ArgumentException:
                    context.Response.StatusCode = (int)HttpStatusCode.BadRequest;
                    response.StatusCode = 400;
                    response.Message = ex.Message;
                    break;

                case UnauthorizedAccessException:
                    context.Response.StatusCode = (int)HttpStatusCode.Unauthorized;
                    response.StatusCode = 401;
                    response.Message = "Unauthorized access.";
                    break;

                default:
                    context.Response.StatusCode = (int)HttpStatusCode.InternalServerError;
                    response.StatusCode = 500;
                    response.Message = "An unexpected error occurred. Please try again later.";
                    break;
            }

            // In Development mode, include exception details for debugging
            if (_env.IsDevelopment())
            {
                response.Details = ex.Message;
            }

            var json = JsonSerializer.Serialize(response,
                new JsonSerializerOptions { PropertyNamingPolicy = JsonNamingPolicy.CamelCase });

            await context.Response.WriteAsync(json);
        }
    }
}

Registering Exception Middleware in Program.cs

The exception middleware must be the first middleware registered. This ensures it catches exceptions from all other middleware and controllers.

// Program.cs
var app = builder.Build();

// Must be FIRST to catch all exceptions
app.UseMiddleware<ExceptionHandlingMiddleware>();

if (app.Environment.IsDevelopment())
{
    app.UseSwagger();
    app.UseSwaggerUI();
}

app.UseMiddleware<RequestLoggingMiddleware>();
app.UseHttpsRedirection();
app.UseAuthentication();
app.UseAuthorization();
app.MapControllers();

app.Run();

Custom Exception Classes

For cleaner error handling, define custom exception types that carry meaning about what went wrong:

// Exceptions/BookNotFoundException.cs
namespace BookStoreAPI.Exceptions
{
    public class BookNotFoundException : Exception
    {
        public BookNotFoundException(int id)
            : base($"Book with Id {id} was not found.")
        {
        }
    }
}
// Exceptions/DuplicateBookException.cs
namespace BookStoreAPI.Exceptions
{
    public class DuplicateBookException : Exception
    {
        public DuplicateBookException(string title)
            : base($"A book with the title '{title}' already exists.")
        {
        }
    }
}

Add these to the middleware's switch statement:

switch (ex)
{
    case BookNotFoundException:
        context.Response.StatusCode = 404;
        response.StatusCode = 404;
        response.Message = ex.Message;
        break;

    case DuplicateBookException:
        context.Response.StatusCode = 409;   // Conflict
        response.StatusCode = 409;
        response.Message = ex.Message;
        break;

    // ... other cases
}

The service throws these exceptions:

// Services/BookService.cs
public async Task<Book?> GetByIdAsync(int id)
{
    var book = await _bookRepository.GetByIdAsync(id);
    if (book == null)
        throw new BookNotFoundException(id);   // ← throws custom exception
    return book;
}

Using the Built-in UseExceptionHandler

ASP.NET Core provides a built-in alternative. It is simpler to set up but less flexible than custom middleware:

// Program.cs (simpler alternative)
app.UseExceptionHandler(errorApp =>
{
    errorApp.Run(async context =>
    {
        context.Response.StatusCode = 500;
        context.Response.ContentType = "application/json";

        await context.Response.WriteAsJsonAsync(new
        {
            statusCode = 500,
            message = "An unexpected error occurred."
        });
    });
});

Error Handling Flow

Request: GET /api/books/999

BooksController.GetById(999)
  → BookService.GetByIdAsync(999)
    → BookRepository.GetByIdAsync(999) → returns null
  → throws BookNotFoundException("Book with Id 999 was not found.")

Exception bubbles up the call stack...

ExceptionHandlingMiddleware catches it:
  → Status 404
  → Response: { "statusCode": 404, "message": "Book with Id 999 was not found." }

Client receives: 404 Not Found
{ "statusCode": 404, "message": "Book with Id 999 was not found." }

Development vs Production Error Responses

EnvironmentError Response Contains
DevelopmentStatus code + message + full exception details (for debugging)
ProductionStatus code + generic message only (no stack trace exposed)

Key Points

  • Global exception middleware wraps the entire request pipeline in a try-catch and handles all unhandled exceptions.
  • Exception middleware must be registered first in Program.cs so it catches exceptions from all later middleware and controllers.
  • Custom exception classes like BookNotFoundException make the code more expressive and allow specific HTTP status codes per exception type.
  • Exception details (stack traces) should only be included in Development mode — never in Production.
  • A consistent error response structure makes it easier for client applications to handle errors predictably.

Leave a Comment