ADR-072: Address platform — geocoding & verification materialized
Date: 2026-06-29 Authors: Jean-Francois Meyers Scope:
Granit.Domain.ValueObjects(Address,AddressGeocoding,AddressVerification,GeoCoordinate),Granit.Geocoding.*(Abstractions / engine / Nominatim / Photon / Endpoints),Granit.AddressEnrichment,Granit.AddressDeliverability.Abstractions,Granit.Persistence.EntityFrameworkCore(MapAddressGeocoding/MapAddressVerification), consumed bygranit-businessParties
Context
Section titled “Context”Multiple modules need to act on addresses — plot them on a Map widget, route
deliveries, offer type-as-you-go entry, decide whether to trust one for billing.
Before this work each reached for an ad-hoc shape: a (lat, lon) pair bolted onto
an entity, a bool IsVerified, a direct call to a hosted geocoder that logged the
address. Three problems recurred:
- Conflation. “We found a coordinate” was treated as “the address is real”,
and “a provider verified it” as the same fact as “a parcel was delivered”. They
are different, and they routinely disagree — a rural address geocodes
Failedyet a courier delivered to it; a well-mapped address geocodesRooftopyet the building was demolished. - Un-queryable storage. Coordinates and verdicts stored as JSON or an opaque
value-converter column (see ADR-070)
can’t answer “every customer whose address is
Failed” or “countDeliveryConfirmedthis quarter” without a table scan. - PII leakage. A postal address is personal data. Hosted geocoders log the request URI (which carries the address), and a plaintext address used as a cache key spreads PII across shared cache infrastructure.
Decision
Section titled “Decision”1. Three orthogonal axes, three value objects
Section titled “1. Three orthogonal axes, three value objects”An address is modelled as three independent mutualized value objects, not one aggregate:
Address— the postal fact (street, city, postal code, country, delivery-point type).AddressGeocoding— the spatial outcome: aStatus(Pending/Resolved/Approximate/Failed/Stale), aGeoCoordinate, aGeocodeMatchPrecision(Rooftop/Street/Locality), freshness, and parsed components.AddressVerification— the deliverability verdict: aStatus(Unverified/ProviderVerified/Corrected/ManuallyConfirmed/DeliveryConfirmed/Invalid) plus theSourcethat produced it.
They are stored side by side on an entity and may freely disagree. Failed
geocoding is not Invalid verification — the distinction is load-bearing.
2. Persist them flat, as queryable columns
Section titled “2. Persist them flat, as queryable columns”Both derived value objects are mapped with EF ComplexProperty through the
MapAddressGeocoding / MapAddressVerification helpers — real, indexable scalar
columns, enums stored as their PascalCase string names (ADR-059).
This is the ComplexProperty opt-in ADR-070
prescribes for a VO column you must filter, sort, or group by: Status,
MatchPrecision, Latitude / Longitude are first-class columns, so analytics
and admin queries translate straight to SQL. The computed GeoCoordinate
projection is not persisted; the scalar Latitude / Longitude pair is the
source of truth. A PostGIS geography(Point) column, when a host opts in, is a
derived spatial index over those same scalars — never the source.
3. A capability model for providers, capability-gated endpoints
Section titled “3. A capability model for providers, capability-gated endpoints”Geocoding providers implement segmented capability interfaces
(IGeocodingProvider forward, IReverseGeocodingProvider reverse,
IAddressAutocompleteProvider autocomplete) rather than one fat interface. The
engine aggregates the registered set into a GeocodingCapabilities record. HTTP
endpoints are mapped only for present capabilities — a Nominatim-only host
exposes /reverse but not /autocomplete, and the OpenAPI document reflects
exactly what the deployment can do. A single provider instance serves every
capability it implements, so all calls share one rate-limit throttle.
4. Three evidence tiers; tier-1 contract named “Deliverability”
Section titled “4. Three evidence tiers; tier-1 contract named “Deliverability””Verification is a verdict backed by evidence of escalating strength: tier 0
geocoding plausibility (free, OSM), tier 1 an authoritative provider
(DPV/RDI), tier 2 real-world evidence (manual confirm, successful delivery).
IAddressEnrichmentService runs the two automatable tiers (0 always; 1 when a
provider is registered, as an optional soft dependency); tier 2 is recorded by the
consuming module and is promote-only — a later provider pass never downgrades
a ManuallyConfirmed / DeliveryConfirmed verdict.
The tier-1 contract package is Granit.AddressDeliverability.Abstractions, named
Deliverability deliberately:
Granit.AddressVerificationwould put a namespace on top of theAddressVerificationvalue object, shadowing it in every consumer.- The
Granit.Validation*family is FluentValidation-based format validation — a different concern from an authoritative real-world check.
No concrete provider ships in the baseline; the contract lets modules depend on deliverability without a vendor.
5. Privacy-first by default
Section titled “5. Privacy-first by default”The default geocoding services are no-ops that resolve to null — reaching a
geocoder requires a deliberate AddGranitGeocoding* call. Addresses and
coordinates are never logged (HTTP-client loggers removed, trace spans redacted)
and never used as a cache key (keys are SHA-256 hashes); autocomplete is not
cached at all. Persisted coordinate and address fields carry
[SensitiveData(Confidential)] and are erased through the consuming module’s
IPrivacyDataProvider. The IP-derived geolocation of a session is a distinct
regime and is deliberately not persisted.
Consequences
Section titled “Consequences”Positive
- One vocabulary for addresses across the framework and business modules; no more
ad-hoc
(lat, lon)+IsVerifiedshapes. - Geocoding, verification, and freshness are queryable columns — admin grids, dashboards, and the Map widget read them directly, no per-render geocoding.
- The three axes can disagree truthfully, so downstream logic (ship / re-check / drop the marker) has the signal it needs.
- PII posture is set once, at the seam, and is safe by default.
- New capabilities (a new provider, autocomplete) light up endpoints without touching consumers.
Negative / trade-offs
- More moving parts than a single
(lat, lon)field: seven packages and two derived value objects. Justified for a cross-cutting concern; overkill for an app that only ever needs a coordinate (which can still callIGeocodingServicedirectly). ComplexPropertyadds columns to every address-holding table, and a nullable complex column needs a shadow discriminator (per ADR-070).- Tier 1 is a contract with no baseline provider — deliverability stays at tier 0 until a host integrates a vendor, accepting that vendor as a GDPR sub-processor.
- Host responsibility for the HTTP endpoints: they must apply a per-principal rate limit, or the shared upstream quota is a denial-of-wallet target.