Registering Services
After implementing all components, wire them together in a static extension method on IServiceCollection. This is the pattern used by all built-in integrations and is the single call a consumer makes at application startup.
Validation Service
The validation service wires all pipeline components together. Extend AbstractAddressValidationService<TRequest, TApiResponse> and forward the four constructor dependencies to the base class.
[SuppressMessage("Performance", "CA1812:Avoid uninstantiated internal classes", Justification = "Instantiated by DI container")]
internal sealed class AddressValidationService : AbstractAddressValidationService<MyAddressValidationRequest, ApiResponse>
{
public AddressValidationService(IApiRequestAdapter<MyAddressValidationRequest, ApiResponse> requestAdapter,
IApiResponseMapper<ApiResponse> responseMapper,
IValidator<MyAddressValidationRequest> requestValidator,
IValidator<ApiResponse> responseValidator)
: base(requestAdapter, responseMapper, requestValidator, responseValidator)
{
}
}
Note
No additional logic belongs here. The base class manages the full validation pipeline: pre-validating the request, calling the API via the request adapter, validating the response, and mapping it to an IAddressValidationResponse.
Note
It is not necessary for the validation service to be internal, but it is strongly recommended if redistributing as a library.
Options
Define a configuration options class by extending AbstractServiceOptions. The base class provides ClientEnvironment and the optional EndpointUriOverride; you only need to implement the abstract EndpointUri property and declare any provider-specific required fields.
public sealed class MyServiceOptions : AbstractServiceOptions
{
public const string SectionName = "AddressValidationSettings:MyProvider";
public override Uri EndpointUri =>
ClientEnvironment switch
{
ClientEnvironment.PRODUCTION => new Uri("https://api.myprovider.example.com"),
ClientEnvironment.SANDBOX => EndpointUriOverride!,
_ => new Uri("https://api-dev.myprovider.example.com"),
};
[Required(AllowEmptyStrings = false)]
public required string ClientId { get; set; }
[Required(AllowEmptyStrings = false)]
public required string ClientSecret { get; set; }
}
Alongside the options class, add a source-generated validator:
[OptionsValidator]
public sealed partial class MyServiceOptionsValidator : IValidateOptions<MyServiceOptions> { }
Note
The [OptionsValidator] attribute generates validation of all [Required] and other data-annotation attributes declared on the options class at compile time. No manual validation code is needed unless cross-property rules are required; the cross-property SANDBOX/EndpointUriOverride rule is already handled by AbstractServiceOptions.
Extension Method
Register all components using a single IServiceCollection extension method:
public static class ServiceCollectionExtensions
{
public static IServiceCollection AddMyProviderAddressValidation(this IServiceCollection services)
{
ArgumentNullException.ThrowIfNull(services);
services.AddOptions<MyServiceOptions>()
.BindConfiguration(MyServiceOptions.SectionName)
.ValidateOnStart();
services.TryAddSingleton<IValidateOptions<MyServiceOptions>, MyServiceOptionsValidator>();
services.TryAddSingleton<MyAuthenticationService>();
services.TryAddScoped<IApiResponseMapper<ApiResponse>, AddressValidationResponseMapper>();
services.TryAddScoped<IApiRequestMapper<MyAddressValidationRequest, ApiRequest>, AddressValidationRequestMapper>();
services.TryAddScoped<IValidator<MyAddressValidationRequest>, AddressValidationRequestValidator>();
services.TryAddScoped<IValidator<ApiResponse>, ApiResponseValidator>();
services.TryAddScoped<IApiRequestAdapter<MyAddressValidationRequest, ApiResponse>, ApiRequestAdapter>();
services.TryAddScoped<IAddressValidationService<MyAddressValidationRequest>, AddressValidationService>();
services.AddHttpClient<MyAuthenticationClient>()
.AddAuthenticationClientResilienceHandler();
services.AddHttpClient<MyAddressValidationClient>()
.RedactLoggedHeaders(["Authorization",])
.AddHttpMessageHandler(provider =>
{
MyAuthenticationService authenticationService = provider.GetRequiredService<MyAuthenticationService>();
return new BearerTokenDelegatingHandler<MyAuthenticationClient>(authenticationService);
})
.AddAddressValidationClientResilienceHandler();
return services;
}
}
Important
Use TryAddSingleton and TryAddScoped rather than AddSingleton and AddScoped. This prevents double-registration if the extension method is called more than once and allows consumers to substitute their own implementations before calling it.
Note
The authentication service is registered as Singleton because it holds the HybridCache-backed access token and must share that state across the application lifetime. All other integration services are Scoped.
Important
HybridCache is required for authentication caching. Please see the Caching section for details.
Note
ValidateOnStart() surfaces configuration errors — such as missing required properties or an invalid SANDBOX/EndpointUriOverride combination — immediately at application startup rather than on the first validation request.
Important
The type argument to BearerTokenDelegatingHandler<TClient> must match the exact authentication client type registered via AddHttpClient<TClient>(). A mismatch results in a runtime failure when the DI container tries to resolve the handler.
Usage
Once registered, inject IAddressValidationService<TRequest> wherever address validation is needed:
public class ValidateController
{
private readonly IAddressValidationService<MyAddressValidationRequest> _validationService;
public ValidateController(IAddressValidationService<MyAddressValidationRequest> validationService)
{
_validationService = validationService ?? throw new ArgumentNullException(nameof(validationService));
}
[HttpPost]
public async Task<IActionResult> Post([FromBody] MyAddressValidationRequest request, CancellationToken cancellationToken = default)
{
IAddressValidationResponse? response = await _validationService.ValidateAsync(request, cancellationToken);
return response is null
? new NotFoundResult()
: response.Errors.Count > 0
? new UnprocessableEntityObjectResult(response)
: new OkObjectResult(response);
}
}