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