Skip to content

ErrorLens.ErrorHandling -- Architecture Guide

This document provides a comprehensive architectural overview of the ErrorLens.ErrorHandling library, a structured error handling framework for ASP.NET Core APIs. It covers the package structure, middleware pipeline, dependency injection graph, handler chain of responsibility, configuration system, and all cross-cutting concerns.

Version covered: 1.4.0 Target frameworks: .NET 6.0 through .NET 10.0 (multi-targeting) Language: C# 12


Table of Contents

  1. Package Structure
  2. High-Level Architecture
  3. Error Handling Pipeline Flow
  4. Dependency Injection Registration
  5. Handler Chain of Responsibility
  6. Configuration Architecture
  7. Attribute System and Metadata Caching
  8. Serialization and Response Writing
  9. ProblemDetails Generation (RFC 9457)
  10. Localization Architecture
  11. Telemetry and Distributed Tracing
  12. Rate Limiting Integration
  13. OpenAPI / Swagger Integration
  14. Multi-Targeting Strategy
  15. Key Design Decisions
  16. Public Interface Catalog

1. Package Structure

The solution ships as four NuGet packages, each with a distinct responsibility and target framework range.

PackageTarget FrameworksPurpose
ErrorLens.ErrorHandlingnet6.0 - net10.0Core middleware, handlers, mappers, models, configuration, localization, telemetry, rate limiting, serialization
ErrorLens.ErrorHandling.FluentValidationnet6.0 - net10.0FluentValidation integration (.NET 6-10); depends on core package and FluentValidation >= 11.0.0
ErrorLens.ErrorHandling.OpenApinet9.0 - net10.0IOpenApiOperationTransformer integration for the native .NET 9+ OpenAPI pipeline
ErrorLens.ErrorHandling.Swashbucklenet6.0 - net8.0IOperationFilter integration for Swashbuckle/Swagger on older frameworks

Both OpenAPI packages share the internal ErrorResponseSchemaGenerator class from the core package (via InternalsVisibleTo), ensuring schema generation logic is defined once.

Core Package Directory Layout

src/ErrorLens.ErrorHandling/
  Attributes/              -- ResponseErrorCode, ResponseStatus, ResponseErrorProperty
  Configuration/           -- ErrorHandlingOptions, enums, JsonFieldNamesOptions, validator
  Extensions/              -- ServiceCollection, ApplicationBuilder, ConfigurationBuilder
  Handlers/                -- IApiExceptionHandler chain + AbstractApiExceptionHandler
  Integration/             -- Middleware, IExceptionHandler, ErrorResponseWriter
  Internal/                -- ExceptionMetadataCache, StringUtils
  Localization/            -- IErrorMessageLocalizer + NoOp + StringLocalizer bridge
  Mappers/                 -- IErrorCodeMapper, IErrorMessageMapper, IHttpStatusMapper
  Models/                  -- ApiErrorResponse, ApiFieldError, ApiGlobalError, ApiParameterError
  OpenApi/                 -- ErrorResponseSchemaGenerator, OpenApiOptions (internal)
  ProblemDetails/          -- IProblemDetailFactory, ProblemDetailFactory, ProblemDetailResponse
  RateLimiting/            -- IRateLimitResponseWriter, DefaultRateLimitResponseWriter, options
  Serialization/           -- ApiErrorResponseConverter (custom JSON field names)
  Services/                -- ErrorHandlingFacade, ILoggingService, LoggingService,
                              IApiErrorResponseCustomizer, ILoggingFilter
  Telemetry/               -- ErrorHandlingActivitySource

2. High-Level Architecture

The architecture follows a Facade pattern at its core. The ErrorHandlingFacade orchestrates handler selection, customization, logging, localization, and telemetry enrichment. The ErrorResponseWriter handles format selection (standard JSON vs. RFC 9457 Problem Details) and serialization with cached JsonSerializerOptions.


3. Error Handling Pipeline Flow

This diagram shows the complete lifecycle of an exception from throw to HTTP response.

Key pipeline behaviors

  • Guard clause: If Enabled is false, the exception is rethrown preserving the original stack trace via ExceptionDispatchInfo.Capture().Throw().
  • Handler safety net: If any handler throws during processing, the facade catches it, logs both exceptions, and returns a safe 500 response (INTERNAL_SERVER_ERROR / "An unexpected error occurred") to prevent information leakage.
  • Response-already-started guard: ErrorResponseWriter checks HttpContext.Response.HasStarted before writing, preventing InvalidOperationException when headers have already been sent.

4. Dependency Injection Registration

The AddErrorHandling() extension method on IServiceCollection registers all core services. The registration uses TryAdd* methods throughout to enable idempotent calls and allow user overrides.

Registration semantics

MethodIdempotencyNotes
TryAddSingleton<I, T>First registration winsUsed for mappers, logging, facade, writer
TryAddEnumerable(Singleton<I, T>)One per concrete typeUsed for handlers and customizers
Replace(ServiceDescriptor)Overwrites existingUsed by AddErrorHandlingLocalization<T>()
AddExceptionHandler<T>()Framework method.NET 8+ IExceptionHandler registration

All services are registered as Singleton. They resolve IOptions<ErrorHandlingOptions>.Value once in their constructors, meaning configuration values are frozen at first resolution.


5. Handler Chain of Responsibility

Handlers implement IApiExceptionHandler and are ordered by their Order property (ascending -- lower values execute first). The facade iterates through them calling CanHandle() until one matches. If none match, the IFallbackApiExceptionHandler handles the exception.

HandlerOrderException TypeStatus CodeError Code
AggregateExceptionHandler50AggregateExceptionDelegates to innerRe-dispatches
ModelStateValidationExceptionHandler90ModelStateValidationException400VALIDATION_FAILED
ValidationExceptionHandler100ValidationException400VALIDATION_FAILED
FluentValidationExceptionHandler110FluentValidation.ValidationException400VALIDATION_FAILED
JsonExceptionHandler120JsonException400MESSAGE_NOT_READABLE
TypeMismatchExceptionHandler130FormatException, InvalidCastException400TYPE_MISMATCH
BadRequestExceptionHandler150BadHttpRequestExceptionfrom exceptionBAD_REQUEST
DefaultFallbackHandlerN/AAnyAttribute / mapperAttribute / mapper

Custom handlers added via AddApiExceptionHandler<T>() participate in the same chain. The AbstractApiExceptionHandler base class provides Order => 1000 by default and a CreateResponse() helper.

AggregateException unwrapping

AggregateExceptionHandler flattens nested aggregates via Flatten(). If there is exactly one inner exception, it lazily resolves handlers from IServiceProvider (to break circular DI), skips itself to prevent recursion, and re-dispatches to the chain. Multi-exception aggregates are delegated to the fallback handler.


6. Configuration Architecture

Configuration binding order

  1. IConfiguration section binding runs first (from appsettings.json, YAML, environment variables, etc.)
  2. The inline Action<ErrorHandlingOptions> runs second, so it can override file-based values.

Options validation

ErrorHandlingOptionsValidator runs at first IOptions<ErrorHandlingOptions> resolution and validates:

  • All JsonFieldNamesOptions properties are non-null and non-whitespace.
  • All field name values are unique (no duplicates like code and message mapped to the same JSON key).
  • ProblemDetailTypePrefix is a valid absolute URI or empty string.
  • RateLimiting.ErrorCode is not null or empty.
  • RateLimiting.DefaultMessage is not null or empty.

ErrorHandlingOptions properties

PropertyTypeDefaultDescription
EnabledbooltrueEnable/disable error handling globally
DefaultErrorCodeStrategyErrorCodeStrategyAllCapsAllCaps, FullQualifiedName, KebabCase, PascalCase, DotSeparated
HttpStatusInJsonResponseboolfalseInclude HTTP status code in JSON body
SearchSuperClassHierarchyboolfalseSearch base classes for config matches
AddPathToErrorbooltrueInclude property path in field errors
OverrideModelStateValidationboolfalseIntercept [ApiController] validation
UseProblemDetailFormatboolfalseEnable RFC 9457 Problem Details format
ProblemDetailTypePrefixstringhttps://example.com/errors/Type URI prefix for Problem Details
ProblemDetailConvertToKebabCasebooltrueConvert error codes to kebab-case in type URI
ExceptionLoggingExceptionLoggingMessageOnlyNone, MessageOnly, WithStacktrace
FallbackMessagestring"An unexpected error occurred"Configurable fallback message for unhandled exceptions
BuiltInMessagesDictionary<string, string>{}Override messages for built-in error codes
HttpStatusesDictionary<string, HttpStatusCode>{}Exception FQN to HTTP status mappings
CodesDictionary<string, string>{}Exception FQN or field-specific error code mappings
MessagesDictionary<string, string>{}Exception FQN or field-specific message mappings
LogLevelsDictionary<string, LogLevel>{}HTTP status code/range to log level
FullStacktraceHttpStatusesHashSet<string>{}HTTP statuses that force full stack trace logging
FullStacktraceClassesHashSet<string>{}Exception types that force full stack trace logging
JsonFieldNamesJsonFieldNamesOptions(defaults)Custom JSON field names (11 configurable fields)
RateLimitingRateLimitingOptions(defaults)Rate limiting response options
OpenApiOpenApiOptionsDefaultStatusCodes: {400, 404, 500}OpenAPI schema generation options

7. Attribute System and Metadata Caching

Three attributes allow exception classes to declare their error response behavior declaratively:

ExceptionMetadataCache is a static, thread-safe ConcurrentDictionary that reflects on exception types once and caches the results. The DefaultFallbackHandler consults this cache to:

  1. Use [ResponseErrorCode] as the error code (overrides the IErrorCodeMapper strategy).
  2. Use [ResponseStatus] as the HTTP status code (overrides the IHttpStatusMapper).
  3. Extract [ResponseErrorProperty]-decorated properties and add them to ApiErrorResponse.Properties (serialized via [JsonExtensionData]).

Property names default to camelCase conversion of the C# property name unless overridden via the Name parameter.


8. Serialization and Response Writing

ErrorResponseWriter caches a single JsonSerializerOptions instance at construction, including the custom ApiErrorResponseConverter. This avoids per-request allocation.

ApiErrorResponseConverter is a write-only JsonConverter<ApiErrorResponse> that:

  • Uses the configured JsonFieldNamesOptions to determine property names at every level.
  • Manually writes each nested error model (ApiFieldError, ApiGlobalError, ApiParameterError) with the correct field names.
  • Filters Properties (extension data) to avoid collisions with built-in field names.
  • The JsonSerializerOptions instance becomes thread-safe after first serialization.

9. ProblemDetails Generation (RFC 9457)

When UseProblemDetailFormat is true, ErrorResponseWriter delegates to IProblemDetailFactory.

The type URI is built from ProblemDetailTypePrefix + the error code converted to kebab-case (e.g., VALIDATION_FAILED becomes validation-failed). This is configurable via ProblemDetailConvertToKebabCase.


10. Localization Architecture

Localization is a zero-cost opt-in. The default NoOpErrorMessageLocalizer is a pass-through. When the user calls AddErrorHandlingLocalization<TResource>():

  1. AddLocalization() is called to register the Microsoft localization infrastructure.
  2. The IErrorMessageLocalizer registration is replaced with StringLocalizerErrorMessageLocalizer<TResource>.
  3. Error codes are used as resource keys in .resx files. If no resource is found, the default message is returned.

Localization runs after handler processing and customization in the ErrorHandlingFacade, so it localizes the final message.


11. Telemetry and Distributed Tracing

ErrorHandlingActivitySource exposes a static ActivitySource named "ErrorLens.ErrorHandling". The facade:

  1. Starts an activity named "ErrorLens.HandleException" for each exception.
  2. Only enriches tags/events when activity.IsAllDataRequested == true (zero overhead when no collector is configured).
  3. Uses OTel semantic conventions for exception events (exception.type, exception.message, exception.stacktrace).
  4. Sets ActivityStatusCode.Error on the activity.

12. Rate Limiting Integration

Available on .NET 7+ only (guarded by #if NET7_0_OR_GREATER).

The DefaultRateLimitResponseWriter:

  • Reads MetadataName.RetryAfter from the RateLimitLease.
  • Sets Retry-After header (always when metadata available).
  • Optionally sets the combined RateLimit header (IETF draft format) via UseModernHeaderFormat.
  • Optionally includes retryAfter in the JSON body via IncludeRetryAfterInBody.
  • Delegates body serialization to the shared ErrorResponseWriter (same format/field name logic).

13. OpenAPI / Swagger Integration

The library provides two parallel packages for API documentation, split by framework generation.

Both integrations:

  • Skip status codes already declared on the operation (e.g., via [ProducesResponseType]).
  • Add error response schemas for each status code in OpenApiOptions.DefaultStatusCodes (default: 400, 404, 500).
  • Choose between standard and Problem Details schemas based on UseProblemDetailFormat.
  • Use the configured JsonFieldNamesOptions for property names in the standard schema.

14. Multi-Targeting Strategy

Feature.NET 6.NET 7.NET 8+
Middleware (UseErrorHandling())RequiredRequiredOptional
IExceptionHandlerN/AN/AAuto-registered
Rate limiting integrationN/AAvailableAvailable

The System.Threading.RateLimiting NuGet package reference is conditionally excluded on net6.0 via a Condition attribute in the .csproj.


15. Key Design Decisions

1. Facade Pattern over Mediator

The ErrorHandlingFacade centralizes orchestration rather than using a mediator or event bus. This keeps the call path predictable, debuggable, and synchronous.

2. Chain of Responsibility with Explicit Ordering

Handlers use a numeric Order property rather than framework-level ordering attributes. Built-in handlers use values 50-150, leaving Order < 50 for high-priority custom handlers and Order > 150 (default 1000) for standard custom handlers.

3. TryAdd for Idempotent Registration

All DI registrations use TryAddSingleton or TryAddEnumerable, meaning:

  • Calling AddErrorHandling() multiple times is safe.
  • Users can register their own IErrorCodeMapper (etc.) before calling AddErrorHandling() to override defaults.

4. Zero-Cost Abstractions for Optional Features

  • Localization: NoOpErrorMessageLocalizer is a pass-through; no allocation or lookup occurs.
  • Telemetry: Activity tags are only set when IsAllDataRequested == true (no collector = no cost).
  • Customizers: Empty IEnumerable<IApiErrorResponseCustomizer> means zero iterations.

5. Cached Serialization Options

ErrorResponseWriter creates its JsonSerializerOptions (including the custom ApiErrorResponseConverter) once at construction. This avoids per-request allocation.

6. Security by Default

  • 5xx errors in DefaultFallbackHandler return a configurable FallbackMessage (default: "An unexpected error occurred") to prevent information disclosure. The message is customizable via ErrorHandlingOptions.FallbackMessage while still defaulting to a safe generic value.
  • BadRequestExceptionHandler sanitizes messages containing framework-internal type names (Microsoft.*, System.*).

7. Static Metadata Cache

ExceptionMetadataCache uses a static ConcurrentDictionary to avoid repeated reflection on exception types. This is a process-lifetime cache that grows monotonically (appropriate for the bounded set of exception types in any application).

8. Separate OpenAPI Packages by Framework Generation

Rather than using conditional compilation within a single package, the library ships separate packages: .OpenApi for .NET 9+ and .Swashbuckle for .NET 6-8. Both share the internal ErrorResponseSchemaGenerator.


16. Public Interface Catalog

Core Interfaces

InterfaceNamespacePurpose
IApiExceptionHandlerHandlersChain of responsibility handler; implement to handle specific exception types
IFallbackApiExceptionHandlerHandlersCatch-all handler when no specific handler matches
IErrorCodeMapperMappersMaps exceptions to error code strings
IErrorMessageMapperMappersMaps exceptions to human-readable messages
IHttpStatusMapperMappersMaps exceptions to HTTP status codes
ILoggingServiceServicesLogs exception handling events
ILoggingFilterServicesFilters which exceptions should be logged
IApiErrorResponseCustomizerServicesGlobal post-processing hook for all error responses
IErrorMessageLocalizerLocalizationLocalizes error messages by error code
IProblemDetailFactoryProblemDetailsCreates RFC 9457 responses from ApiErrorResponse
IRateLimitResponseWriterRateLimitingWrites structured 429 responses (.NET 7+)

Core Classes

ClassNamespacePurpose
AbstractApiExceptionHandlerHandlersBase class with Order => 1000 and CreateResponse() helper
ErrorHandlingFacadeServicesCentral orchestrator for the entire pipeline
ErrorResponseWriterIntegrationWrites JSON/Problem Details to HttpContext.Response
ErrorHandlingMiddlewareIntegrationIMiddleware for .NET 6/7
ErrorHandlingExceptionHandlerIntegrationIExceptionHandler for .NET 8+
ApiErrorResponseModelsPrimary error response model
ApiFieldErrorModelsField-level validation error
ApiGlobalErrorModelsClass-level validation error
ApiParameterErrorModelsMethod parameter validation error
ProblemDetailResponseProblemDetailsRFC 9457 response model
ErrorHandlingActivitySourceTelemetryStatic ActivitySource for OTel tracing
ApiErrorResponseConverterSerializationCustom JSON converter for configurable field names

Extension Methods

MethodClassPurpose
AddErrorHandling()ServiceCollectionExtensionsRegister all core services
AddApiExceptionHandler<T>()ServiceCollectionExtensionsRegister custom exception handler
AddErrorResponseCustomizer<T>()ServiceCollectionExtensionsRegister response customizer
AddErrorHandlingLocalization<T>()ServiceCollectionExtensionsOpt into IStringLocalizer-based localization
UseErrorHandling()ApplicationBuilderExtensionsAdd middleware to pipeline (required .NET 6/7)
AddYamlErrorHandling()ConfigurationBuilderExtensionsAdd YAML configuration source
AddErrorHandlingOpenApi()OpenApiServiceCollectionExtensionsAdd .NET 9+ OpenAPI schemas
AddErrorHandlingSwashbuckle()SwaggerServiceCollectionExtensionsAdd Swashbuckle operation filter

Attributes

AttributeTargetPurpose
[ResponseErrorCode("CODE")]Class (Exception)Override error code for an exception type
[ResponseStatus(HttpStatusCode)]Class (Exception)Override HTTP status for an exception type
[ResponseErrorProperty]Property (on Exception)Include property value in error response JSON

Released under the MIT License.