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
| Environment | Error Response Contains |
|---|---|
| Development | Status code + message + full exception details (for debugging) |
| Production | Status 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.csso it catches exceptions from all later middleware and controllers. - Custom exception classes like
BookNotFoundExceptionmake 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.
