This document describes the internal architecture of the VisusIO.AddressValidation library suite. It is intended for contributors and maintainers who need to understand how the codebase is structured, how the runtime pipeline works, and what contracts must be satisfied when adding a new provider integration.
AddressValidation/
├── src/
│ ├── Visus.AddressValidation/ Core abstractions, base classes, shared HTTP infrastructure
│ ├── Visus.AddressValidation.Integration.FedEx/ FedEx provider integration
│ ├── Visus.AddressValidation.Integration.Google/ Google Address Validation provider integration
│ ├── Visus.AddressValidation.Integration.PitneyBowes/ Pitney Bowes provider integration
│ ├── Visus.AddressValidation.Integration.Ups/ UPS provider integration
│ └── Visus.AddressValidation.SourceGeneration/ Roslyn incremental source generator
└── tests/
├── Visus.AddressValidation.Tests/
├── Visus.AddressValidation.Integration.FedEx.Tests/
├── Visus.AddressValidation.Integration.Google.Tests/
├── Visus.AddressValidation.Integration.PitneyBowes.Tests/
├── Visus.AddressValidation.Integration.Ups.Tests/
└── Visus.AddressValidation.SourceGeneration.Tests/
Each integration package is independently versioned and published to NuGet. Consumers install only the packages they need. All packages target net10.0 and are built with IsAotCompatible = true. The source generator targets netstandard2.0 as required by the Roslyn host.
The VisusIO.AddressValidation package defines every interface and base class shared across providers. Integration packages depend on it; it never depends on them.
| Type | Role |
|---|---|
AbstractAddressValidationRequest |
Base request class. Holds the five canonical address fields (AddressLines, CityOrTown, Country, PostalCode, StateOrProvince). Automatically normalizes US territories (GU, PR, VI → US), clears StateOrProvince for city-states, and nullifies PostalCode for countries where the concept does not apply. |
IAddressValidationResponse |
Unified response interface returned to all consumers regardless of provider. Exposes AddressLines, CityOrTown, Country, PostalCode, StateOrProvince, IsResidential, Errors, Warnings, Suggestions, and CustomResponseData. |
AbstractAddressValidationResponse |
Base concrete implementation of IAddressValidationResponse. Errors and warnings are backed by FrozenSet. Equality is implemented via AddressValidationResponseEqualityComparer. |
EmptyAddressValidationResponse |
A null-object response returned when request validation fails before any API call is made, or when the raw API response fails post-call validation. |
CountryCode |
ISO 3166-1 Alpha-2 enum (247 values, numeric codes matching the ISO standard). Serialized as a string via JsonStringEnumConverter. |
ClientEnvironment |
DEVELOPMENT, PRODUCTION, or SANDBOX. Controls which endpoint URI a provider client resolves to. |
IAddressValidationService<TRequest> is the single public-facing interface for library consumers:
Task<IAddressValidationResponse?> ValidateAsync(TRequest request, CancellationToken cancellationToken)
Consumers depend only on this interface and IAddressValidationResponse; they never reference any provider-specific type.
AbstractAddressValidationService<TRequest, TApiResponse> is the template method base for all provider services. It owns the five-step pipeline (see Validation Pipeline below) and coordinates the injected collaborators: IValidator<TRequest>, IApiRequestAdapter<TRequest, TApiResponse>, and IApiResponseMapper<TApiResponse>.
AbstractBatchAddressValidationService<TRequest, TApiResponse> is an opt-in template method base implementing IBatchAddressValidationService<TRequest> for providers whose API natively accepts multiple addresses in a single call. It coordinates IValidator<TRequest> (the same per-request validator used by the singular pipeline — there is no separate batch request validator), IBatchApiRequestAdapter<TRequest, TApiResponse>, IBatchApiResponseMapper<TApiResponse>, and IBatchValidator<TApiResponse>. See Batch Validation Pipeline below. It is a peer of AbstractAddressValidationService, not a replacement — a provider that supports batching registers both, and consumers choose IAddressValidationService<TRequest> or IBatchAddressValidationService<TRequest> depending on whether they have one address or many.
AbstractAuthenticationService<TClient> handles OAuth 2.0 client-credentials token acquisition and caching. Concrete subclasses implement only GenerateCacheKey(). See Authentication & Token Caching.
AbstractValidator<T> defines a two-phase template:
PreValidateAsync— fast early-abort check (e.g., null country, unsupported country).ValidateAsync— full rule evaluation (address lines, city, state/province, postal code).
AbstractAddressValidationRequestValidator<T> extends this with the shared rules that apply to all providers. Provider-specific validators extend it further to add their own rules and supply their SupportedCountries frozen set and ProviderName.
AbstractBatchValidator<T> is the batch counterpart, used to validate a batch API response. Unlike AbstractValidator<T>, which produces a single shared ISet<ValidationState>, it produces one independent ISet<ValidationState> per item sent, indexed positionally: PreValidateAsync and ValidateAsync both receive IReadOnlyList<int> requestIndexes (the original, caller-facing index of each request that was actually sent) alongside IReadOnlyList<ISet<ValidationState>> results (positionally aligned with the sent batch, not with requestIndexes). Batch-wide conditions (a top-level error payload, a result count that doesn't match the number of items sent) are handled in PreValidateAsync and broadcast to every item's result set; per-item conditions are handled in ValidateAsync.
| Type | Role |
|---|---|
BearerTokenDelegatingHandler |
DelegatingHandler that injects Authorization: Bearer <token> into every outbound request to the provider API. Throws InvalidCredentialException if the token is absent. |
AbstractBasicAuthenticationClient |
Abstract OAuth2 client that authenticates using HTTP Basic auth (used by UPS and Pitney Bowes). |
HttpClientBuilderExtensions |
Two resilience pipeline presets registered via Microsoft.Extensions.Http.Resilience. See HTTP Resilience. |
Every integration package follows an identical internal directory layout:
<Provider>/
├── Abstractions/ Provider-specific enumerations (alert types, address classifications, etc.)
├── Adapters/ IApiRequestAdapter implementation — bridges service to the typed HTTP client
├── Clients/ Typed HttpClients for authentication and address validation
├── Configuration/ ServiceOptions POCO + IValidateOptions validator
├── Constants.cs Endpoint URIs, supported locales, supported country sets
├── Contracts/ Raw API request/response POCOs (serialization shapes)
├── Extensions/ ServiceCollectionExtensions — the public DI registration entry point
├── Mappers/ Request mapper (domain → API) and response mapper (API → domain)
├── Models/ Concrete request and response types exposed to consumers
├── Resources/ Localizable .resx files for validation error messages
├── Serialization/ JsonSerializerContext for AOT-safe JSON serialization
├── Services/ Concrete AddressValidationService and AuthenticationService
└── Validation/ Request validator and API response validator
Providers whose API natively supports validating multiple addresses in one call (currently only FedEx) add batch-specific files alongside their singular counterparts in the same directories rather than introducing new top-level directories: Adapters/BatchApiRequestAdapter.cs, Mappers/BatchAddressValidationRequestMapper.cs and Mappers/BatchAddressValidationResponseMapper.cs, Services/BatchAddressValidationService.cs, and Validation/BatchApiResponseValidator.cs. Per-address mapping/validation logic shared between the singular and batch code paths is factored into internal static helpers (e.g. FedEx's FedExAddressToValidateMapper, ResolvedAddressResponseMapper, ResolvedAddressValidator) and called from both.
The design combines the Template Method and Port/Adapter patterns:
- Template Method: All pipeline orchestration lives in the abstract base classes. A new provider implementation inherits and fills in the abstract members; it never re-implements the pipeline logic.
- Port/Adapter: Each pipeline step is expressed as a narrow interface (
IApiRequestAdapter,IApiRequestMapper,IApiResponseMapper,IValidator<T>, and their batch counterpartsIBatchApiRequestAdapter,IBatchApiRequestMapper,IBatchApiResponseMapper,IBatchValidator<T>). Implementations are independently testable and replaceable. The service itself only depends on these interfaces, never on concrete HTTP or serialization details.
This combination means that adding a new provider requires writing the concrete types for each slot, but the orchestration, caching, resilience, and validation plumbing is inherited for free. The same applies to opting a provider into batch support: only the batch-specific adapter, mappers, and response validator need to be written — request validation, DI registration patterns, and HTTP/resilience infrastructure are reused as-is.
The following diagram shows the full request/response flow through AbstractAddressValidationService:
flowchart TD
Consumer["Consumer\ncalls ValidateAsync()"]
PreVal["PreValidateAsync\n(country non-null & supported)"]
FullVal["ValidateAsync\n(address lines, city, state, postal code\n+ provider-specific rules)"]
Empty1["Return EmptyAddressValidationResponse"]
MapReq["IApiRequestMapper\ndomain request → API contract POCO"]
Adapter["IApiRequestAdapter\nexecute HTTP call via typed client"]
ValResp["ApiResponseValidator\nvalidate raw API response"]
Empty2["Return EmptyAddressValidationResponse"]
MapResp["IApiResponseMapper\nAPI contract POCO → IAddressValidationResponse\n(includes GetCustomResponseData())"]
Consumer2["Return IAddressValidationResponse to consumer"]
Consumer --> PreVal
PreVal -- "fails" --> Empty1
PreVal -- "passes" --> FullVal
FullVal -- "fails" --> Empty1
FullVal -- "passes" --> MapReq
MapReq --> Adapter
Adapter --> ValResp
ValResp -- "fails" --> Empty2
ValResp -- "passes" --> MapResp
MapResp --> Consumer2
Steps 1–2 run entirely in-process. Steps 3–5 involve the network. Any unhandled exception from the HTTP client propagates to the caller; the resilience pipeline (see below) handles transient failures transparently before the exception surfaces.
AbstractBatchAddressValidationService<TRequest, TApiResponse>.ValidateManyAsync extends the same shape as the singular pipeline, but partitions the input first so that a single API call can cover every locally-valid request:
flowchart TD
Consumer["Consumer\ncalls ValidateManyAsync(requests)"]
SizeCheck["requests.Count > MaxBatchSize?"]
Throw["Throw ArgumentException\n(no requests reach the provider)"]
Partition["Validate each request locally\n(same IValidator<TRequest> as the singular pipeline)"]
Empty1["Position i = EmptyAddressValidationResponse\n(locally-invalid requests)"]
AnyValid["Any locally-valid requests?"]
MapReq["IBatchApiRequestMapper\nvalid requests → single API contract POCO"]
Adapter["IBatchApiRequestAdapter\nexecute one HTTP call via typed client"]
NullResp["Response null?"]
NullSlots["Remaining positions = null"]
ValResp["IBatchValidator\none IValidationResult per item sent,\npositionally aligned with the sent batch"]
MapResp["IBatchApiResponseMapper\nfor each item: HasErrors → EmptyAddressValidationResponse,\notherwise map API item at its sent-batch position"]
Reassemble["Reassemble into a list positionally\naligned with the original requests"]
Consumer2["Return IReadOnlyList<IAddressValidationResponse?>"]
Consumer --> SizeCheck
SizeCheck -- "yes" --> Throw
SizeCheck -- "no" --> Partition
Partition -- "invalid" --> Empty1
Partition --> AnyValid
AnyValid -- "none valid" --> Reassemble
AnyValid -- "some valid" --> MapReq
MapReq --> Adapter
Adapter --> NullResp
NullResp -- "yes" --> NullSlots --> Reassemble
NullResp -- "no" --> ValResp
ValResp --> MapResp
MapResp --> Reassemble
Empty1 --> Reassemble
Reassemble --> Consumer2
Key points that distinguish this from the singular pipeline:
- Two index spaces.
requestIndexes(the original, caller-facing position inrequests) and the sent-batch position (the index within the subset of requests that actually passed local validation and were sent to the provider) are not the same once any request fails local validation.IBatchValidator<T>.ExecuteAsync,AbstractBatchValidator<T>, andIBatchApiResponseMapper<T>.Mapall operate on sent-batch position;requestIndexesis used only when a message needs to reference the request's original position (e.g.[Row 3]in a validation message). - One HTTP call, many outcomes. A single
TApiResponse(ornull) covers the whole sent batch. The returned list is always the same length asrequestsand positionally aligned with it:nullonly at positions that held a locally-valid request when the adapter itself returned no response at all;EmptyAddressValidationResponseat positions that failed local validation or that the provider could not resolve; a mapped response otherwise. - Result correlation is positional, not identity-based. Neither this pipeline nor any current batch-capable provider correlates a response item back to its request by an echoed identifier — FedEx, for example, does not return any per-item field on a resolved address that traces back to the request (
ClientReferenceIdis transmitted for FedEx's own tracking only; seeFedExAddressValidationRequest.ClientReferenceId). Correlation instead depends entirely on the provider preserving the order ofIBatchApiRequestMapper<TRequest, TApiRequest>.Map's input in its response array, which is an assumption, not a verified contract —IBatchApiRequestAdapter<TRequest, TApiResponse>implementations must submit requests preserving order for this reason. - Diagnostics differ slightly. The activity name is
address_validation.validate_many(vs.address_validation.validate), tagged withaddress_validation.batch_size;address_validation.countryuses thebatchsentinel since a batch may span multiple countries. Per-item warning/suggestion count metrics are recorded once per item (mirroring the singular pipeline's per-call recording), not once for the whole batch. Seedocs/docs/instrumentation.mdfor the full tag/metric reference. - The per-item result count is defensively checked.
IBatchValidator<T>.ExecuteAsyncmust return exactly oneIValidationResultper item inrequestIndexes;AbstractBatchValidator<T>guarantees this for any correctly-derived implementation.AbstractBatchAddressValidationServicestill checks the returned count before indexing into it, throwingInvalidImplementationExceptionon a mismatch rather than an opaque index-out-of-range failure.
All providers use the OAuth 2.0 client-credentials grant. The flow is:
- Before the first address validation request,
BearerTokenDelegatingHandlercallsAbstractAuthenticationService.GetTokenAsync(). GetTokenAsyncchecksHybridCachefor a live token under the provider-specific cache key.- On a cache miss, the factory lambda calls the typed
IAuthenticationClient, obtains aTokenResponse, and stores the token with a TTL ofExpiresIn − 60seconds (60-second safety buffer to avoid using an about-to-expire token). HybridCacheprovides built-in stampede protection: concurrent cache misses for the same key execute the factory only once.
Cache keys are validated at construction time. They must consist solely of alphanumeric characters, underscores, hyphens, and colons, and are prefixed with vs-ave-auth:. An invalid key throws InvalidImplementationException at startup rather than silently failing at runtime.
The resilience pipeline for authentication clients is intentionally conservative: retries are disabled for POST requests (non-idempotent), and the circuit-breaker threshold is lower than for validation clients.
Two resilience pipelines are registered via Microsoft.Extensions.Http.Resilience (Polly v8):
- Retries disabled for unsafe HTTP methods (
POST) to avoid duplicate token requests. - Reduced circuit-breaker failure threshold — fail fast to surface credential problems quickly.
- Full standard resilience pipeline (timeout, retry with exponential back-off, circuit breaker, bulkhead).
- Smart
Retry-Afterparsing for HTTP 429 responses: prefersdelta-secondsformat, falls back to absolute date, defaults to 10 seconds if the header is absent or unparseable.
Both pipelines are registered once inside each provider's ServiceCollectionExtensions.Add<Provider>AddressValidation() extension method and are invisible to the consumer.
The library is designed to work in Native AOT and trimmed deployments:
IsAotCompatible = trueis set inDirectory.Build.propsand propagates to all non-test projects.- All JSON serialization uses source-generated
JsonSerializerContext(one per project, registered with[JsonSerializable]attributes). NoJsonSerializeroverloads that acceptTypeat runtime are used. TokenResponseuses a customJsonConverter<TokenResponse>to handle polymorphic OAuth response shapes without reflection.CustomResponseDatapopulation on response objects is performed by source-generated code (see Roslyn Source Generator) rather than by runtime reflection over properties.FrozenSetis used for all read-only, lookup-heavy collections (supported countries, city-states, no-postal-code countries) to maximize lookup performance after startup.
The VisusIO.AddressValidation package ships a bundled Roslyn source generator (CustomResponseDataGenerator) that targets netstandard2.0.
Provider API response contract types (the POCOs in each Contracts/ directory) expose fields that are not part of the normalized IAddressValidationResponse but that consumers may still want to inspect. Rather than forcing consumers to cast to a provider-specific type, these fields are surfaced through IAddressValidationResponse.CustomResponseData as IReadOnlyDictionary<string, object?>.
Populating that dictionary via runtime reflection would break AOT. The source generator eliminates reflection by generating the population code at compile time.
- Annotate any property on a response contract POCO with
[CustomResponseDataProperty]. An optional argument overrides the default camelCase dictionary key. - At compile time,
CustomResponseDataGeneratordiscovers all types with such annotations via an incremental syntax transform. - For each discovered type it emits a
partial class(orpartial record) in the same namespace, containing aGetCustomResponseData()method that returnsIReadOnlyDictionary<string, object?>populated directly from the annotated properties — no reflection, no runtime scanning. AddressValidationResponseMappercallsGetCustomResponseData()when constructing the finalIAddressValidationResponse.
The generator handles nested types, sealed types, and records, and uses ContainingTypeInfo / PropertyInfo value types to keep the incremental pipeline cache-friendly.
All provider integrations depend on Microsoft.Extensions.Caching.Hybrid.HybridCache. Register it before any provider:
services.AddHybridCache();
Each integration package exposes a single extension method on IServiceCollection:
| Package | Extension Method | Configuration Section |
|---|---|---|
| FedEx | AddFedExAddressValidation() |
AddressValidationSettings:FedEx |
AddGoogleAddressValidation() |
AddressValidationSettings:Google |
|
| Pitney Bowes | AddPitneyBowesAddressValidation() |
AddressValidationSettings:PitneyBowes |
| UPS | AddUpsAddressValidation() |
AddressValidationSettings:Ups |
Each extension method registers: IOptions<TServiceOptions> (with .ValidateOnStart()), IValidateOptions<TServiceOptions>, IAddressValidationService<TRequest>, IAuthenticationService, IApiRequestMapper, IApiResponseMapper, IValidator<TRequest>, IApiRequestAdapter, and the two typed HttpClient registrations (authentication + validation) with their respective resilience handlers. For providers with batch support, the same extension method additionally registers IBatchAddressValidationService<TRequest>, IBatchApiRequestAdapter, IBatchApiRequestMapper, IBatchApiResponseMapper, and IBatchValidator<TApiResponse> — there is no separate Add<Provider>BatchAddressValidation() method, since the batch and singular pipelines share the same options, HTTP client, and authentication service.
Multiple providers can be registered simultaneously in the same IServiceCollection; they do not conflict because all registrations are keyed on provider-specific generic type parameters.
Provider options classes extend AbstractServiceOptions and implement IValidatableObject. The cross-provider invariant enforced by AbstractServiceOptions is that ClientEnvironment.SANDBOX requires an explicit EndpointUriOverride. Provider options extend this with their own rules (e.g., FedEx validates the BCP-47 locale tag against its supported set; all providers enforce that credentials are non-empty strings).
- Base class behavior is covered in isolation using NSubstitute mocks and a real
HybridCacheinstance from a minimalServiceProvider. - Key scenarios: cache hit, cache miss, null/empty/whitespace token, concurrent requests with stampede protection (10 simultaneous calls, factory runs exactly once), invalid cache key format.
AbstractBatchAddressValidationServiceis covered separately from the singular service: constructor guard clauses, empty/oversized batch handling, all-locally-invalid vs. some-locally-invalid vs. all-valid partitioning (asserting only valid requests reach the adapter, in original order), adapter returningnull(with and without some requests already locally invalid), per-item response validation failures leaving other items unaffected, and thesuccess/partial/invalid_request/no_response/erroractivity result tags.
- Tests use WireMock.Net to run a local HTTP server that replays real JSON fixtures from each project's
Fixtures/directory. - The full
IServiceCollectionis assembled with the realAdd<Provider>AddressValidation()extension method; only theEndpointUriOverrideis pointed at the WireMock server. - Scenarios covered per provider: happy path (fully resolved address), interpolated/ambiguous address (warnings), API error responses (4xx), empty/null/failed OAuth token, concurrent requests.
- For providers with batch support (FedEx), additional scenarios cover: all addresses resolving cleanly in original order, a batch-wide API error response applied to every valid item, exceeding
MaxBatchSize, one item failing while the others still map, a request before it in the batch failing local validation (asserting the resulting error message references the request's original index, not its position in the sent batch), only locally-valid requests reaching the API, and a batch-wideWARNINGalert applied to every resolved item.
Every project contains an ApiTests.cs that generates the full public API surface as text using PublicApiGenerator and asserts it matches a committed .verified.txt snapshot file via Verify. This prevents accidental breaking changes from merging silently.
- C# source strings are compiled in-memory using the Roslyn
CSharpGeneratorDriver. - Generated output is compared against committed
.verified.cssnapshot files via Verify.SourceGenerators. - Scenarios: root-level class, nested class.
All test projects use TUnit with the Microsoft Testing Platform runner (configured in global.json). Assertions use AwesomeAssertions. Test data is provided by AutoFixture with AutoNSubstitute. Coverage is collected via dotnet-coverage and merged into a single report for SonarCloud.
All NuGet version pins live exclusively in Directory.Packages.props. Individual .csproj files never specify version numbers. This prevents version drift across the six source projects and six test projects. Renovate bot manages automated dependency update PRs.
Validation error messages surfaced to library consumers are stored in .resx files inside each project's Resources/ directory. Crowdin manages translations into six languages (de-DE, fr-FR, it-IT, es-ES, pt-BR, pt-PT). Only approved translations are exported. Crowdin opens signed PRs automatically; no manual translation workflow is required.
.editorconfigenforces C# style rules across all projects: novar, Allman-style braces,s_prefix for private static fields,ConfigureAwaitrequired on all awaits (enforced as an error), explicit accessibility modifiers.Directory.Build.propsenablesTreatWarningsAsErrors,EnforceCodeStyleInBuild, and all analyzer categories viaAnalysisMode = AllEnabledByDefault.- Meziantou.Analyzer and Roslynator.Analyzers are injected into every project automatically via
Directory.Packages.props. - SonarCloud performs static analysis on every CI run.