Exception Mappers

Exception mappers transform exceptions into structured error responses. Create custom mappers for your domain exceptions to return meaningful HTTP responses with appropriate status codes and error data.


Creating a Custom Mapper

Inherit from ExceptionMapper<TException> and implement the MapException method:

using System.Net;
using AspNetConventions.ExceptionHandling.Mappers;
using AspNetConventions.ExceptionHandling.Models;
using AspNetConventions.Http.Services;
using Microsoft.Extensions.Logging;

public class OrderNotFoundExceptionMapper : ExceptionMapper<OrderNotFoundException>
{
    public override ExceptionDescriptor MapException(
        OrderNotFoundException exception,
        RequestDescriptor request)
    {
        return new ExceptionDescriptor
        {
            Type = "ORDER_NOT_FOUND",
            StatusCode = HttpStatusCode.NotFound,
            Message = exception.Message,
            Value = new { exception.OrderId },
            LogLevel = LogLevel.Warning,
            ShouldLog = true
        };
    }
}

Registering Your Mapper

builder.Services.AddControllers()
    .AddAspNetConventions(options =>
    {
        options.ExceptionHandling.Mappers.Add(new OrderNotFoundExceptionMapper());
    });

Registering via Assembly Scanning

Instead of adding each mapper by hand, use ScanAssemblies to discover and register every mapper in one or more assemblies automatically:

builder.Services.AddControllers()
    .AddAspNetConventions(options =>
    {
        // Discovers all IExceptionMapper implementations in the given assembly
        options.ExceptionHandling.ScanAssemblies(typeof(OrderNotFoundExceptionMapper).Assembly);
    });

Mappers that require constructor dependencies are skipped, register those manually (see Mappers with Dependencies).

Scanning composes safely with manual registration. A mapper type already present in Mappers is not added again, so you can combine both approaches:

// Scan a project, then add a dependency-injected mapper manually
options.ExceptionHandling.ScanAssemblies(typeof(OrderNotFoundExceptionMapper).Assembly);
options.ExceptionHandling.Mappers.Add(loggingExceptionMapper);

Building ExceptionDescriptor

The ExceptionDescriptor controls every aspect of the error response:

Simple Descriptor

return new ExceptionDescriptor
{
    Type = "INVALID_REQUEST",
    StatusCode = HttpStatusCode.BadRequest,
    Message = "The request was invalid."
};

Full Descriptor

return new ExceptionDescriptor
{
    Type = "PAYMENT_FAILED",
    StatusCode = HttpStatusCode.PaymentRequired,
    Message = "Payment processing failed.",
    Value = new
    {
        TransactionId = exception.TransactionId,
        ErrorCode = exception.ErrorCode,
        Reason = exception.Reason
    },
    ShouldLog = true,
    LogLevel = LogLevel.Critical
};

See ExceptionDescriptor for more information.


Common Mapper Patterns

Not Found Exception

public class ResourceNotFoundException : Exception
{
    public string ResourceType { get; }
    public string ResourceId { get; }

    public ResourceNotFoundException(string resourceType, string resourceId)
        : base($"{resourceType} '{resourceId}' was not found.")
    {
        ResourceType = resourceType;
        ResourceId = resourceId;
    }
}

public class ResourceNotFoundExceptionMapper : ExceptionMapper<ResourceNotFoundException>
{
    public override ExceptionDescriptor MapException(
        ResourceNotFoundException exception,
        RequestDescriptor request)
    {
        return new ExceptionDescriptor
        {
            Type = "RESOURCE_NOT_FOUND",
            StatusCode = HttpStatusCode.NotFound,
            Message = exception.Message,
            Value = new
            {
                exception.ResourceType,
                exception.ResourceId
            },
            LogLevel = LogLevel.Warning
        };
    }
}

Validation Exception

public class BusinessValidationException : Exception
{
    public Dictionary<string, string[]> Errors { get; }

    public BusinessValidationException(Dictionary<string, string[]> errors)
        : base("Business validation failed.")
    {
        Errors = errors;
    }
}

public class BusinessValidationExceptionMapper : ExceptionMapper<BusinessValidationException>
{
    public override ExceptionDescriptor MapException(
        BusinessValidationException exception,
        RequestDescriptor request)
    {
        return new ExceptionDescriptor
        {
            Type = "BUSINESS_VALIDATION_ERROR",
            StatusCode = HttpStatusCode.UnprocessableEntity,
            Message = exception.Message,
            Value = exception.Errors,
            ShouldLog = false  // Expected validation errors don't need logging
        };
    }
}

Conflict Exception

public class DuplicateEntityException : Exception
{
    public string EntityType { get; }
    public string ConflictingField { get; }
    public object ConflictingValue { get; }

    public DuplicateEntityException(string entityType, string field, object value)
        : base($"A {entityType} with {field} '{value}' already exists.")
    {
        EntityType = entityType;
        ConflictingField = field;
        ConflictingValue = value;
    }
}

public class DuplicateEntityExceptionMapper : ExceptionMapper<DuplicateEntityException>
{
    public override ExceptionDescriptor MapException(
        DuplicateEntityException exception,
        RequestDescriptor request)
    {
        return new ExceptionDescriptor
        {
            Type = "DUPLICATE_ENTITY",
            StatusCode = HttpStatusCode.Conflict,
            Message = exception.Message,
            Value = new
            {
                EntityType = exception.EntityType,
                Field = exception.ConflictingField,
                Value = exception.ConflictingValue
            },
            LogLevel = LogLevel.Warning
        };
    }
}

Rate Limit Exception

public class RateLimitExceededException : Exception
{
    public int RetryAfterSeconds { get; }
    public string Limit { get; }

    public RateLimitExceededException(int retryAfter, string limit)
        : base("Rate limit exceeded.")
    {
        RetryAfterSeconds = retryAfter;
        Limit = limit;
    }
}

public class RateLimitExceptionMapper : ExceptionMapper<RateLimitExceededException>
{
    public override ExceptionDescriptor MapException(
        RateLimitExceededException exception,
        RequestDescriptor request)
    {
        return new ExceptionDescriptor
        {
            Type = "RATE_LIMIT_EXCEEDED",
            StatusCode = HttpStatusCode.TooManyRequests,
            Message = $"Rate limit exceeded. Retry after {exception.RetryAfterSeconds} seconds.",
            Value = new
            {
                RetryAfterSeconds = exception.RetryAfterSeconds,
                Limit = exception.Limit
            },
            ShouldLog = false  // Expected condition, no need to log
        };
    }
}

External Service Exception

public class ExternalServiceException : Exception
{
    public string ServiceName { get; }
    public int? ServiceErrorCode { get; }

    public ExternalServiceException(string serviceName, string message, int? errorCode = null)
        : base(message)
    {
        ServiceName = serviceName;
        ServiceErrorCode = errorCode;
    }
}

public class ExternalServiceExceptionMapper : ExceptionMapper<ExternalServiceException>
{
    public override ExceptionDescriptor MapException(
        ExternalServiceException exception,
        RequestDescriptor request)
    {
        return new ExceptionDescriptor
        {
            Type = "EXTERNAL_SERVICE_ERROR",
            StatusCode = HttpStatusCode.BadGateway,
            Message = $"External service '{exception.ServiceName}' is unavailable.",
            Value = new
            {
                Service = exception.ServiceName,
                ErrorCode = exception.ServiceErrorCode
            },
            LogLevel = LogLevel.Error
        };
    }
}

Mappers with Dependencies

For mappers that need injected services, create them with dependencies and register appropriately:

public class LoggingExceptionMapper : ExceptionMapper<MyException>
{
    private readonly ILogger<LoggingExceptionMapper> _logger;
    private readonly IMetricsService _metrics;

    public LoggingExceptionMapper(
        ILogger<LoggingExceptionMapper> logger,
        IMetricsService metrics)
    {
        _logger = logger;
        _metrics = metrics;
    }

    public override ExceptionDescriptor MapException(
        MyException exception,
        RequestDescriptor request)
    {
        // Use injected services
        _metrics.IncrementCounter("my_exception_count");

        return new ExceptionDescriptor
        {
            Type = "MY_ERROR",
            StatusCode = HttpStatusCode.InternalServerError,
            Message = exception.Message
        };
    }
}

// Registration with DI
var serviceProvider = builder.Services.BuildServiceProvider();
var mapper = new LoggingExceptionMapper(
    serviceProvider.GetRequiredService<ILogger<LoggingExceptionMapper>>(),
    serviceProvider.GetRequiredService<IMetricsService>());

builder.Services.AddControllers()
    .AddAspNetConventions(options =>
    {
        options.ExceptionHandling.Mappers.Add(mapper);
    });

Logging Best Practices

When to Log:

Scenario ShouldLog LogLevel
Unexpected errors true Error or Critical
Client errors (bad input) true Warning
Validation failures false
Rate limiting false
Not found (expected) true Warning or Information
External service failures true Error

Log Level Guidelines:

// Critical — System is unusable
LogLevel = LogLevel.Critical  // Database down, critical service failure

// Error — Operation failed
LogLevel = LogLevel.Error     // Unexpected exceptions, failed operations

// Warning — Something unexpected but handled
LogLevel = LogLevel.Warning   // Not found, invalid arguments

// Information — Normal operation details
LogLevel = LogLevel.Information  // Successful but noteworthy events