Table of Contents

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);
    }
}