Skip to content

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.

PainThis module’s answer
Each consumer wires its own geocoder client with a different key, retry, and rate limitThree 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 fileBoth 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 resolvedThe 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 blockedA per-provider token-bucket throttle, shared across forward / reverse / autocomplete, defaulting to 1 req/s
Re-geocoding the same address on every dashboard renderA FusionCache layer keyed on a hashed address — successes and failures cached on separate clocks, raw address never in the key
  • DirectoryGranit.Geocoding.Abstractions/ Contracts: the three I*Service seams, the three I*Provider capability interfaces, PostalAddress, GeocodingResult, GeocodingCapabilities
    • DirectoryGranit.Geocoding/ Engine: no-op default services, the hashed-key FusionCache layer, ProviderOrder fallback, capability aggregation
      • DirectoryGranit.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)

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 → coordinate
Task<GeocodingResult?> GeocodeAsync(PostalAddress address, CancellationToken ct = default);
// Reverse — coordinate → address
Task<ReverseGeocodingResult?> ReverseAsync(GeoCoordinate coordinate, CancellationToken ct = default);
// Autocomplete — partial text → suggestions
Task<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.

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 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 interfaceNominatimPhoton
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.

Geocoding binds three option sections under Geocoding.

OptionDefaultRole
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.
SuccessCacheDuration90dTTL for a cached successful geocode. Coordinates for a real address are stable, so this is long to slash provider traffic.
FailureCacheDuration1dTTL for a cached miss. Short, so a transient failure (provider cold, address mid-correction) retries soon without pinning a marker as missing for months.
OptionDefaultRole
BaseAddresshttps://nominatim.openstreetmap.orgRoot 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.
RateLimitPerSecond1Token-bucket ceiling. 1 matches the public instance’s hard limit; raise it only against an instance you control.
Timeout5sPer-request timeout; on expiry the lookup returns null.
MaxResponseSizeBytes262144Upper bound on the buffered response body — hardening against oversized OSM fields.
ProviderNameNominatimIdentifier used in ProviderOrder.
OptionDefaultRole
BaseAddresshttps://photon.komoot.ioRoot URL of your Photon instance. The demo is fine for trials but discourages bulk and production use.
LanguagenullOptional lang parameter (en, de, fr, …) for localised place names; affects text only, never the coordinate.
UserAgentnullOptional identifying header — Photon doesn’t mandate one, but it makes your own access logs attributable.
RateLimitPerSecond1Token-bucket ceiling; the public komoot endpoint is free but fair-use.
Timeout5sPer-request timeout; on expiry the lookup returns null.
MaxResponseSizeBytes262144Upper bound on the buffered response body.
ProviderNamePhotonIdentifier used in ProviderOrder.

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 + reverse
builder.AddGranitGeocodingPhoton(); // + autocomplete
appsettings.json
{
"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
}
}

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.

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).
  • MinimisationPostalAddress carries 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.