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