Skip to content

The address platform — three axes, three evidence tiers, one capability model

Addresses look simple and behave like anything but. A system that acts on them — ships parcels, plots customers on a map, lets users pick one as they type — keeps tripping over the same conflations: treating “we found a coordinate” as “the address is real”, re-geocoding the same row on every render, or shipping a personal address to a third-party API that logs it. The address platform is the set of Granit packages that settle these questions once, so every module handles addresses the same way.

It rests on three ideas: an address has three independent axes, its deliverability is established through three evidence tiers, and providers expose their abilities through a capability model.

An address carries three separate facts, and they do not move together:

AxisValue objectAnswers
PostalAddressWhat did the user type?
GeocodingAddressGeocodingWhere is it on a map?
VerificationAddressVerificationIs it real and deliverable?

The independence is the whole point. A brand-new rural address can be geocoding-Failed (no OpenStreetMap node for the lane) yet DeliveryConfirmed because a courier physically dropped a parcel there last week. A typo-perfect city-centre address geocodes to a crisp Rooftop coordinate yet stays Unverified because nothing has ever confirmed a person receives mail there — or even verifies Invalid, because the building was demolished. Collapse the three into one “is the address good?” boolean and you throw away exactly the distinctions that drive the next decision: plot the marker, ship the parcel, or ask the user to re-check.

These are mutualized value objects in the base Granit package, persisted as flat, queryable columns — so “show every customer whose address is Failed” or “count parties DeliveryConfirmed this quarter” is a plain SQL filter. See Address value objects for the full API and persistence model.

The verification axis isn’t a single yes/no — it’s a verdict backed by evidence of escalating strength. The platform defines three tiers, and records which one produced the verdict so a weak guess is never mistaken for a hard fact:

TierEvidenceStrengthWhere it runs
0Geocoding plausibility — the address exists on OSM at some precisionWeak (existence, not deliverability)Granit.Geocoding — free, self-hostable
1Authoritative provider — DPV / RDI via Smarty, Loqate, Google Address ValidationStrongGranit.AddressDeliverability.Abstractions — contract only, no provider in the baseline
2Real-world evidence — an operator confirmed, or a delivery / mailing succeededStrongestthe consuming module (e.g. Parties)

Tiers 0 and 1 are automatable — a machine runs them unattended, which is exactly what the enrichment orchestrator does: tier 0 always, tier 1 when an authoritative provider is registered. Tier 2 can only come from the real world, so the consuming module records it — and, crucially, a tier-2 verdict is never downgraded by a later tier-0/1 pass.

flowchart TD
    addr["Address"] --> enr["IAddressEnrichmentService"]
    enr --> t0["Tier 0 — geocode (always)"]
    enr -. "provider registered" .-> t1["Tier 1 — deliverability (optional)"]
    t0 --> geo["AddressGeocoding"]
    t1 --> ver["AddressVerification"]
    module["Consuming module<br/>(operator confirm, courier delivery)"] -->|"Tier 2"| ver

Geocoding providers differ in what they can do — Nominatim does precise forward and reverse lookups; Photon adds typo-tolerant autocomplete. Rather than a lowest-common-denominator interface, providers implement segmented capability interfaces, and the engine aggregates them into a GeocodingCapabilities record:

CapabilityNominatimPhoton
Forward (address → coordinate)
Reverse (coordinate → address)
Autocomplete (typeahead)

The payoff is honest surfaces: the HTTP endpoints map /autocomplete only when an autocomplete-capable provider is installed, so a deployment’s OpenAPI document reflects exactly what it can actually do — no runtime “not supported” errors. See Geocoding — the capability matrix.

granit-business is the reference consumer. Its PartyAddress entity owns all three axes — the postal Address, plus AddressGeocoding (default Pending) and AddressVerification (default Unverified) — mapped to flat columns with the framework’s MapAddressGeocoding / MapAddressVerification helpers. From there:

  • Enrichment runs off the request path. Adding, updating, or removing an address raises a domain event that dispatches an on-demand geocode job; a recurring sweep (every five minutes) catches Pending / Stale / Failed rows that a missed dispatch or a provider outage left behind. Both call IAddressEnrichmentService, and verification is promote-only — a provider pass never overwrites a tier-2 ManuallyConfirmed / DeliveryConfirmed verdict.
  • Tier-2 evidence has an endpoint. POST /parties/{id}/addresses/{addressId}/confirm records a ManuallyConfirmed (operator) or DeliveryConfirmed (courier / postal) verdict; the inverse records Invalid when a delivery comes back.
  • Analytics reads the flat columns. The dashboard Map widget’s LatLng point source reads the materialized Latitude / Longitude straight off the geocoding columns — no per-render geocoding.

A coordinate that pins where a named person lives is personal data, and the platform treats it that way end to end: address and coordinate fields carry [SensitiveData(Confidential)], geocoding never logs an address or coordinate and never puts either in a cache key (keys are hashed), third-party geocoding and deliverability are deliberate opt-ins (the default geocoding service is a no-op), and on a subject-erasure request the consuming module erases the stored address through its IPrivacyDataProvider.