Skip to content

Address enrichment — one Address in, two value objects out

An entity that holds an Address almost always wants the two derived axes beside it — where the address geocodes, and whether it’s deliverable. Wiring each consumer to call the geocoder, then maybe a verification provider, then map both onto the value objects, is the kind of boilerplate that drifts between modules. Granit.AddressEnrichment is the one orchestrator that does it.

public interface IAddressEnrichmentService
{
Task<AddressEnrichmentResult> EnrichAsync(Address address, CancellationToken ct = default);
}
public sealed record AddressEnrichmentResult(
AddressGeocoding Geocoding,
AddressVerification Verification);

One Address in; the two value objects an address-holding entity persists alongside the postal text out — ready to hand to MapAddressGeocoding / MapAddressVerification.

EnrichAsync runs the evidence tiers the platform defines — the two that a machine can run unattended:

flowchart LR
    addr["Address"] --> svc["IAddressEnrichmentService"]
    svc --> t0["Tier 0 — geocode<br/>(always)"]
    svc -. "when a provider is registered" .-> t1["Tier 1 — deliverability<br/>(optional)"]
    t0 --> geo["AddressGeocoding<br/>(Resolved / Failed)"]
    t1 --> ver["AddressVerification<br/>(ProviderVerified / Corrected / Invalid)"]
    geo --> res["AddressEnrichmentResult"]
    ver --> res

Tier 0 — geocoding (always). The service geocodes address.ToPostalAddress() through IGeocodingService. A hit becomes AddressGeocoding.Resolved(coordinate, precision, …) (carrying the parsed house number); a miss becomes AddressGeocoding.Failed(now). Because the geocoding service is itself a no-op when no provider is registered, tier 0 degrades gracefully to Failed rather than throwing.

Tier 1 — deliverability (optional). If — and only if — an IAddressDeliverabilityService is registered, the service checks the address against it and maps the provider’s outcome onto the verification verdict. With no provider, Verification stays AddressVerification.Unverified and tier 1 is silently skipped.

Provider outcomeRecorded verdictSource
VerifiedProviderVerifiedVerificationProvider
CorrectedCorrectedVerificationProvider
InvalidInvalidVerificationProvider
UnverifiableUnverified

The provider’s raw match code is carried into the verdict’s Evidence field for provenance.

[DependsOn(typeof(GranitAddressEnrichmentModule))]
public sealed class CrmModule : GranitModule { }

GranitAddressEnrichmentModule depends on GranitGeocodingModule, so a geocoding service is always present (the no-op default when no provider is installed). IAddressEnrichmentService is registered scoped. Deliverability is wired as a soft dependency — register a provider to light up tier 1, leave it out to stay at geocoding plausibility:

public sealed class CustomerWriter(IAddressEnrichmentService enrichment)
{
public async Task SetAddressAsync(Customer customer, Address address, CancellationToken ct)
{
AddressEnrichmentResult e = await enrichment.EnrichAsync(address, ct);
customer.SetAddress(address, e.Geocoding, e.Verification);
}
}