Geocoding — forward, reverse, and autocomplete behind one privacy-first contract
Plenty of features end up needing the same handful of answers about an address: where is it on a map? (a dashboard Map widget plotting customers), what address is at this pin? (a field worker dropping a marker), what is the user half-way through typing? (an address autocomplete on a form). The naive shape is one HTTP call to a hosted geocoder per consumer — each with its own key, its own rate limit, and its own habit of logging the full address it just sent over the wire.
That last part matters more than it looks. A postal address is personal data. Shipping one to a third-party geocoder is a cross-border transfer of PII, and echoing it into structured logs is a second disclosure most teams never notice.
Granit.Geocoding is the single seam — three small capability contracts,
resolved by DI, so every consumer geocodes the same way and the privacy posture
is set once. Two provider packages implement it against OpenStreetMap data, both
built to run self-hosted:
Granit.Geocoding.Nominatim (Nominatim) for precise
structured lookups, and Granit.Geocoding.Photon (Photon)
for typo-tolerant, type-as-you-go search and reverse lookups. Register whichever
fits, in whatever order.
| Pain | This module’s answer |
|---|---|
| Each consumer wires its own geocoder client with a different key, retry, and rate limit | Three DI-resolved contracts — IGeocodingService, IReverseGeocodingService, IAddressAutocompleteService — every consumer uses the same seam |
| Geocoding a personal address ships PII to a third country with no DPA on file | Both providers can target a self-hosted instance; the default contract is a no-op that resolves to null until you deliberately register a provider |
| The geocoder client logs the full address it just resolved | The contract never logs address or coordinate fields; the HTTP loggers are stripped and a redaction handler scrubs the request URI from trace spans |
| Hammering the public Nominatim endpoint trips its 1 req/s policy and gets you blocked | A per-provider token-bucket throttle, shared across forward / reverse / autocomplete, defaulting to 1 req/s |
| Re-geocoding the same address on every dashboard render | A FusionCache layer keyed on a hashed address — successes and failures cached on separate clocks, raw address never in the key |
Package structure
Section titled “Package structure”DirectoryGranit.Geocoding.Abstractions/ Contracts: the three
I*Serviceseams, the threeI*Providercapability interfaces,PostalAddress,GeocodingResult,GeocodingCapabilitiesDirectoryGranit.Geocoding/ Engine: no-op default services, the hashed-key FusionCache layer,
ProviderOrderfallback, capability aggregationDirectoryGranit.Geocoding.Nominatim/ Provider — Nominatim / OSM: forward + reverse
- …
DirectoryGranit.Geocoding.Photon/ Provider — Photon (Komoot): forward + reverse + autocomplete
- …
DirectoryGranit.Geocoding.Endpoints/ Capability-gated HTTP endpoints (
/geocoding/autocomplete,/geocoding/reverse)- …
Three contracts, one privacy guarantee
Section titled “Three contracts, one privacy guarantee”The engine is split into three capabilities so a consumer depends only on what it uses, and so a host maps only the endpoints its provider can actually serve.
// Forward — address → coordinateTask<GeocodingResult?> GeocodeAsync(PostalAddress address, CancellationToken ct = default);
// Reverse — coordinate → addressTask<ReverseGeocodingResult?> ReverseAsync(GeoCoordinate coordinate, CancellationToken ct = default);
// Autocomplete — partial text → suggestionsTask<IReadOnlyList<AddressSuggestion>> SuggestAsync(string query, int limit = 5, CancellationToken ct = default);Every one is null-safe and exception-free. A forward or reverse lookup that
finds nothing returns null; autocomplete returns an empty list. An
unresolvable address is a normal outcome, not an error — a Map widget over 10 000
rows never aborts on one bad street name. And with no provider registered, all
three are no-ops (null / empty), so a host that hasn’t opted into geocoding
never accidentally calls out to a third party.
The wire types
Section titled “The wire types”public sealed record PostalAddress( string? Street, // number + name, or null string? PostalCode, // or null string Locality, // city / town — required string Country); // name or ISO 3166-1 alpha-2 — required
public sealed record GeocodingResult( GeoCoordinate Coordinate, GeocodeMatchPrecision Precision, // Rooftop | Street | Locality string? HouseNumber = null, string? PostalCode = null, string? CountryCode = null);
public sealed record ReverseGeocodingResult(PostalAddress Address, GeocodeMatchPrecision Precision);
public sealed record AddressSuggestion( string Label, PostalAddress Address, GeoCoordinate? Coordinate, GeocodeMatchPrecision? Precision);The parsed HouseNumber / PostalCode / CountryCode on a forward result are
best-effort enrichment: a host stores HouseNumber on the geocoding value object,
and cross-checks the returned PostalCode / CountryCode against the submitted
address to flag a likely typo. A provider that returns only a coordinate leaves
them null.
Providers and the capability matrix
Section titled “Providers and the capability matrix”Providers don’t implement the consumer services directly. Each implements one or
more capability interfaces, and the engine aggregates them. A provider that
can reverse-geocode also implements IReverseGeocodingProvider; one built for
typeahead also implements IAddressAutocompleteProvider.
| Capability interface | Nominatim | Photon |
|---|---|---|
IGeocodingProvider (forward) | ✓ | ✓ |
IReverseGeocodingProvider (reverse) | ✓ | ✓ |
IAddressAutocompleteProvider (autocomplete) | ✗ | ✓ |
Nominatim’s usage policy discourages per-keystroke queries, so it deliberately
omits autocomplete; Photon is built for search-as-you-type and offers all three.
Register one provider, or both — when both are registered, ProviderOrder sets
the fallback chain. A single provider instance serves every capability it
implements, so forward, reverse, and autocomplete calls all pace through the
same rate-limit throttle.
flowchart LR
caller["Consumer<br/>(Map widget, form, …)"] --> svc["I*Service<br/>(forward / reverse / autocomplete)"]
svc -->|"cache hit (hashed key)"| cache[("FusionCache<br/>success 90d / failure 1d")]
svc -->|"cache miss"| order["ProviderOrder<br/>fallback chain"]
order --> throttle["Per-provider throttle<br/>RateLimitPerSecond"]
throttle --> prov["Provider<br/>(Nominatim / Photon)"]
prov -->|"query"| inst["OSM instance<br/>(self-hosted or public)"]
The engine exposes a GeocodingCapabilities(bool Forward, bool Autocomplete, bool Reverse) record, computed once from the registered provider set. The
HTTP endpoints read it to map
/autocomplete and /reverse only when a capable provider is installed.
Configuration
Section titled “Configuration”Geocoding binds three option sections under Geocoding.
Geocoding — the engine
Section titled “Geocoding — the engine”| Option | Default | Role |
|---|---|---|
ProviderOrder | [] | Ordered list of ProviderNames — the fallback chain, tried in turn until one matches. Empty = all registered providers in registration order. Non-empty = an allow-list; a registered provider absent from it is excluded. |
SuccessCacheDuration | 90d | TTL for a cached successful geocode. Coordinates for a real address are stable, so this is long to slash provider traffic. |
FailureCacheDuration | 1d | TTL for a cached miss. Short, so a transient failure (provider cold, address mid-correction) retries soon without pinning a marker as missing for months. |
Geocoding:Nominatim
Section titled “Geocoding:Nominatim”| Option | Default | Role |
|---|---|---|
BaseAddress | https://nominatim.openstreetmap.org | Root URL. Point it at your own instance for any non-trivial volume — the public one is rate-capped and prohibits heavy/commercial use. |
UserAgent | (empty — required) | Sent on every request. Nominatim’s policy mandates a real identifying agent; startup validation fails until it is set. |
RateLimitPerSecond | 1 | Token-bucket ceiling. 1 matches the public instance’s hard limit; raise it only against an instance you control. |
Timeout | 5s | Per-request timeout; on expiry the lookup returns null. |
MaxResponseSizeBytes | 262144 | Upper bound on the buffered response body — hardening against oversized OSM fields. |
ProviderName | Nominatim | Identifier used in ProviderOrder. |
Geocoding:Photon
Section titled “Geocoding:Photon”| Option | Default | Role |
|---|---|---|
BaseAddress | https://photon.komoot.io | Root URL of your Photon instance. The demo is fine for trials but discourages bulk and production use. |
Language | null | Optional lang parameter (en, de, fr, …) for localised place names; affects text only, never the coordinate. |
UserAgent | null | Optional identifying header — Photon doesn’t mandate one, but it makes your own access logs attributable. |
RateLimitPerSecond | 1 | Token-bucket ceiling; the public komoot endpoint is free but fair-use. |
Timeout | 5s | Per-request timeout; on expiry the lookup returns null. |
MaxResponseSizeBytes | 262144 | Upper bound on the buffered response body. |
ProviderName | Photon | Identifier used in ProviderOrder. |
Caching
Section titled “Caching”Geocoding the same address twice is wasted work and wasted rate-limit budget, so the engine wraps every provider in a FusionCache layer. Successes and failures cache on independent clocks (90 days / 1 day by default), and the cache uses stampede protection — concurrent lookups of the same uncached address coalesce onto a single provider call.
The cache key is a SHA-256 hash of the normalised address (or, for reverse, the coordinate rounded to ~1 m); the raw address or coordinate never lands in the key:
granit:geocoding:<sha256(street|postalcode|locality|country)>granit:geocoding:reverse:<sha256(lat,lon rounded to 5 dp)>Autocomplete is not cached — typeahead has a low cache-hit rate and is expected to be debounced by the caller — so a partial query (itself fragmentary personal data) is never persisted to a distributed cache.
Register a provider on the host builder. The AddGranitGeocoding* extension
wires a typed HttpClient, binds and validates the options on start, removes
the default HTTP-client loggers (their request-URI logging would leak the
address), adds a telemetry redaction handler, and disables auto-redirects (an
SSRF hardening). It is the single, deliberate opt-in to an outbound data flow.
builder.AddGranitGeocodingNominatim(); // forward + reversebuilder.AddGranitGeocodingPhoton(); // + autocomplete{ "Geocoding": { "ProviderOrder": ["Nominatim", "Photon"], "SuccessCacheDuration": "90.00:00:00", "FailureCacheDuration": "1.00:00:00", "Nominatim": { "BaseAddress": "https://nominatim.internal.example.com/", "UserAgent": "AcmeLogistics/1.4 (ops@acme.example)" }, "Photon": { "BaseAddress": "https://photon.internal.example.com/" } }}Then inject whichever contract you need — all three are singletons, safe anywhere:
public sealed class DeliveryPlanner(IGeocodingService geocoding){ public async Task<GeoCoordinate?> LocateAsync(Address address, CancellationToken ct) { GeocodingResult? result = await geocoding.GeocodeAsync(address.ToPostalAddress(), ct); return result?.Coordinate; // null when unresolved — a normal outcome }}Map widget — the address point source
Section titled “Map widget — the address point source”The analytics Map widget
plots points with explicit latitude / longitude. When a dataset stores a
postal address rather than coordinates, the widget’s address source resolves
each row through the registered IGeocodingService (cache included) and emits the
same points shape — so adding Granit.Geocoding plus a provider is all the Map
renderer needs. Rows that don’t resolve are dropped from the marker set, not
failed, and without a registered provider the widget surfaces
Widget:Unavailable.GeocodingNotConfigured.
Privacy and GDPR
Section titled “Privacy and GDPR”A postal address that identifies a person is personal data under GDPR. The module’s defaults make the safe path the default path, but the host owns the legal posture.
If you point BaseAddress at a third-party geocoder (the public Nominatim or
Photon demo, Google, Mapbox, …), you are transferring personal data to a
processor, and the host must put the full chain in place:
- Lawful basis — record the Article 6 basis for the transfer before the first call.
- Data Processing Agreement — an Article 28 DPA with the provider; for non-EU providers, a valid transfer mechanism (adequacy decision or SCCs).
- Minimisation —
PostalAddresscarries address fields only, by design. Never pass a name, order id, or customer reference through it. - Records of processing — list the geocoder as a recipient on your Article 30 register.
See also
Section titled “See also”- Address platform overview — the three axes and three evidence tiers geocoding sits inside.
- Address value objects — where
a geocoding result is persisted (
AddressGeocoding). - Address enrichment — the
orchestrator that turns one
Addressinto both derived value objects. - Geocoding endpoints — the
capability-gated
/geocoding/autocompleteand/geocoding/reverseHTTP surface. - Dashboards endpoints — Map widget
— the
pointscontract and theaddresspoint source backed by this module. - Application settings — how the
Geocodingoptions bind and validate on start.