Skip to main content
The BuildingBlocks library provides custom exception types and a global exception handler that converts exceptions into standardized HTTP problem details responses.

Overview

The exception handling system provides:
  • Custom Exception Types: Specific exceptions for common error scenarios
  • Global Exception Handler: Centralized exception handling using ASP.NET Core’s IExceptionHandler
  • Problem Details: Standardized error responses following RFC 7807
  • Automatic Status Codes: Maps exceptions to appropriate HTTP status codes

Custom Exception Types

BadRequestException

Used when the request is invalid or contains bad data.
BuildingBlocks/Exceptions/BadRequestException.cs
namespace BuildingBlocks.Exceptions;

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

    public BadRequestException(string message, string details) : base(message)
    {
        Details = details;
    }

    public string? Details { get; }
}
Usage:
public class CreateOrderCommandHandler : ICommandHandler<CreateOrderCommand, Guid>
{
    public async Task<Guid> Handle(CreateOrderCommand command, CancellationToken ct)
    {
        if (command.Items.Sum(i => i.Quantity) > 1000)
        {
            throw new BadRequestException(
                "Order exceeds maximum quantity",
                "The total quantity of items in the order cannot exceed 1000 units."
            );
        }
        
        // Process order...
    }
}
HTTP Response:
{
  "type": "https://tools.ietf.org/html/rfc7231#section-6.5.1",
  "title": "BadRequestException",
  "status": 400,
  "detail": "Order exceeds maximum quantity",
  "instance": "/api/orders",
  "traceId": "00-abc123..."
}

NotFoundException

Used when a requested resource cannot be found.
BuildingBlocks/Exceptions/NotFoundException.cs
namespace BuildingBlocks.Exceptions;

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

    public NotFoundException(string name, object key) 
        : base($"Entity \"{name}\" ({key}) was not found.")
    {
    }
}
Usage:
public class GetOrderByIdQueryHandler : IQueryHandler<GetOrderByIdQuery, OrderDto>
{
    public async Task<OrderDto> Handle(GetOrderByIdQuery query, CancellationToken ct)
    {
        var order = await _context.Orders
            .FirstOrDefaultAsync(o => o.Id == query.OrderId, ct);
        
        if (order == null)
        {
            throw new NotFoundException(nameof(Order), query.OrderId);
        }
        
        return _mapper.Map<OrderDto>(order);
    }
}
HTTP Response:
{
  "type": "https://tools.ietf.org/html/rfc7231#section-6.5.4",
  "title": "NotFoundException",
  "status": 404,
  "detail": "Entity \"Order\" (a1b2c3d4-e5f6-7890-abcd-ef1234567890) was not found.",
  "instance": "/api/orders/a1b2c3d4-e5f6-7890-abcd-ef1234567890",
  "traceId": "00-abc123..."
}

InternalServerException

Used for internal server errors and unexpected exceptions.
BuildingBlocks/Exceptions/InternalServerException.cs
namespace BuildingBlocks.Exceptions;

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

    public InternalServerException(string message, string details) : base(message)
    {
        Details = details;
    }

    public string? Details { get; }
}
Usage:
public class ProcessPaymentCommandHandler : ICommandHandler<ProcessPaymentCommand, PaymentResult>
{
    public async Task<PaymentResult> Handle(ProcessPaymentCommand command, CancellationToken ct)
    {
        try
        {
            return await _paymentGateway.ProcessPaymentAsync(command, ct);
        }
        catch (PaymentGatewayException ex)
        {
            throw new InternalServerException(
                "Payment processing failed",
                ex.Message
            );
        }
    }
}
HTTP Response:
{
  "type": "https://tools.ietf.org/html/rfc7231#section-6.6.1",
  "title": "InternalServerException",
  "status": 500,
  "detail": "Payment processing failed",
  "instance": "/api/payments",
  "traceId": "00-abc123..."
}

Global Exception Handler

The CustomExceptionHandler implements IExceptionHandler to catch and handle all exceptions globally.
BuildingBlocks/Exceptions/Handler/CustomExceptionHandler.cs
using FluentValidation;
using Microsoft.AspNetCore.Diagnostics;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.Logging;

namespace BuildingBlocks.Exceptions.Handler;

public class CustomExceptionHandler
    (ILogger<CustomExceptionHandler> logger)
    : IExceptionHandler
{
    public async ValueTask<bool> TryHandleAsync(
        HttpContext context, 
        Exception exception, 
        CancellationToken cancellationToken)
    {
        logger.LogError(
            "Error Message: {exceptionMessage}, Time of occurrence {time}",
            exception.Message, DateTime.UtcNow);

        (string Detail, string Title, int StatusCode) details = exception switch
        {
            InternalServerException =>
            (
                exception.Message,
                exception.GetType().Name,
                context.Response.StatusCode = StatusCodes.Status500InternalServerError
            ),
            ValidationException =>
            (
                exception.Message,
                exception.GetType().Name,
                context.Response.StatusCode = StatusCodes.Status400BadRequest
            ),
            BadRequestException =>
            (
                exception.Message,
                exception.GetType().Name,
                context.Response.StatusCode = StatusCodes.Status400BadRequest
            ),
            NotFoundException =>
            (
                exception.Message,
                exception.GetType().Name,
                context.Response.StatusCode = StatusCodes.Status404NotFound
            ),
            _ =>
            (
                exception.Message,
                exception.GetType().Name,
                context.Response.StatusCode = StatusCodes.Status500InternalServerError
            )
        };

        var problemDetails = new ProblemDetails
        {
            Title = details.Title,
            Detail = details.Detail,
            Status = details.StatusCode,
            Instance = context.Request.Path
        };

        problemDetails.Extensions.Add("traceId", context.TraceIdentifier);

        if (exception is ValidationException validationException)
        {
            problemDetails.Extensions.Add("ValidationErrors", validationException.Errors);
        }

        await context.Response.WriteAsJsonAsync(problemDetails, cancellationToken: cancellationToken);
        return true;
    }
}

Key Features

  • Automatic Mapping: Maps exception types to HTTP status codes
  • Structured Logging: Logs all exceptions with timestamp
  • Problem Details: Returns RFC 7807 compliant error responses
  • Trace ID: Includes correlation ID for tracking
  • Validation Errors: Includes detailed validation errors for ValidationException

Exception to Status Code Mapping

Exception TypeHTTP Status CodeStatus
BadRequestException400Bad Request
ValidationException400Bad Request
NotFoundException404Not Found
InternalServerException500Internal Server Error
Other exceptions500Internal Server Error

Registration

Register the exception handler in your Program.cs:
var builder = WebApplication.CreateBuilder(args);

// Add exception handler
builder.Services.AddExceptionHandler<CustomExceptionHandler>();
builder.Services.AddProblemDetails();

var app = builder.Build();

// Use exception handler middleware
app.UseExceptionHandler();

app.Run();

Validation Exception Response

When FluentValidation throws a ValidationException, the handler includes detailed validation errors: Request:
{
  "customerId": "",
  "items": []
}
Response:
{
  "type": "https://tools.ietf.org/html/rfc7231#section-6.5.1",
  "title": "ValidationException",
  "status": 400,
  "detail": "Validation failed: \n -- CustomerId: Customer ID is required\n -- Items: Order must contain at least one item",
  "instance": "/api/orders",
  "traceId": "00-abc123...",
  "validationErrors": [
    {
      "propertyName": "CustomerId",
      "errorMessage": "Customer ID is required",
      "attemptedValue": "",
      "severity": "Error"
    },
    {
      "propertyName": "Items",
      "errorMessage": "Order must contain at least one item",
      "attemptedValue": [],
      "severity": "Error"
    }
  ]
}

Best Practices

Choose the exception type that best represents the error condition.
// Good - Use specific exceptions
if (order == null)
    throw new NotFoundException(nameof(Order), orderId);

if (order.Status == OrderStatus.Cancelled)
    throw new BadRequestException("Cannot modify a cancelled order");

// Avoid - Generic exceptions
throw new Exception("Something went wrong");
Error messages should be clear and actionable for API consumers.
// Good - Clear and actionable
throw new BadRequestException(
    "Payment amount exceeds order total",
    $"Payment of ${payment.Amount} exceeds order total of ${order.Total}"
);

// Avoid - Vague messages
throw new BadRequestException("Invalid payment");
Avoid exposing sensitive information or internal implementation details in error messages.
// Good - Generic message for security issues
throw new BadRequestException("Invalid credentials");

// Avoid - Exposing internal details
throw new InternalServerException(
    "Database connection failed",
    $"Connection string: Server=prod-db;Database=orders;..."
);
Use FluentValidation validators instead of throwing exceptions manually for validation.
// Good - Use FluentValidation
public class CreateOrderCommandValidator : AbstractValidator<CreateOrderCommand>
{
    public CreateOrderCommandValidator()
    {
        RuleFor(x => x.CustomerId).NotEmpty();
    }
}

// Avoid - Manual validation in handler
public async Task<Guid> Handle(CreateOrderCommand command, CancellationToken ct)
{
    if (command.CustomerId == Guid.Empty)
        throw new BadRequestException("Customer ID is required");
}

Error Response Example

Here’s a complete example of how exceptions flow through the system:
// 1. Request comes in
[HttpGet("{id}")]
public async Task<ActionResult<OrderDto>> GetOrder(Guid id)
{
    var query = new GetOrderByIdQuery(id);
    var result = await _mediator.Send(query);
    return Ok(result);
}

// 2. Handler throws exception
public class GetOrderByIdQueryHandler : IQueryHandler<GetOrderByIdQuery, OrderDto>
{
    public async Task<OrderDto> Handle(GetOrderByIdQuery query, CancellationToken ct)
    {
        var order = await _context.Orders.FindAsync(query.OrderId);
        
        if (order == null)
        {
            throw new NotFoundException(nameof(Order), query.OrderId);
        }
        
        return _mapper.Map<OrderDto>(order);
    }
}

// 3. CustomExceptionHandler catches and transforms it
// 4. Client receives:
// HTTP/1.1 404 Not Found
// Content-Type: application/problem+json
{
  "type": "https://tools.ietf.org/html/rfc7231#section-6.5.4",
  "title": "NotFoundException",
  "status": 404,
  "detail": "Entity \"Order\" (a1b2c3d4-...) was not found.",
  "instance": "/api/orders/a1b2c3d4-...",
  "traceId": "00-abc123..."
}

Validation Behavior

Learn how validation exceptions are thrown

CQRS Pattern

See how to throw exceptions in handlers

Build docs developers (and LLMs) love