4. Geocoding and Geographic Search Strategy
Status
Accepted
Date
2026-05-13
Context
Radius/bounding-box search for volunteer opportunities requires each on-site opportunity to have geographic coordinates and the read query to support spatial filtering; the opportunity-detail page’s single-marker map also depends on those persisted coordinates. We need to decide where geocoding runs and how spatial filtering is implemented in PostgreSQL.
Decision
Geocode addresses in the CreateVolunteerOpportunity / UpdateVolunteerOpportunity
application command handlers via an IGeocodingService abstraction backed by
OpenStreetMap Nominatim, and persist the coordinates on the Address value
object. Reads never geocode. Spatial filtering uses a SQL bounding-box
prefilter plus an in-memory Haversine refinement for radius queries. We do not
adopt PostGIS.
|
Note
|
How geocoding is triggered changed after this ADR was accepted - see the Amendment section at the end of this record. The command handlers no longer call |
Rationale
-
Geocoding once at write time keeps reads fast and deterministic, and avoids hammering Nominatim on every list request.
-
A bounding-box filter on plain nullable
address_latitude/address_longitudecolumns (no dedicated spatial index) is sufficient for the platform’s expected dataset size; results are paginated regardless. -
Haversine refinement happens in memory only on the bbox-bounded candidate set, which is small by construction.
-
Nominatim has no API key, is free, and aligns with the project’s open-source self-hosting principle. The HTTP client is throttled to one request per second per the public usage policy, with a short request timeout so unreachable hosts do not stall command handlers.
Considered Alternatives
PostGIS with NetTopologySuite
PostgreSQL extension for proper spatial types and indexes.
-
Pros
-
True geodesic accuracy and GiST/SP-GiST spatial indexes
-
First-class spatial operators directly in EF Core via NetTopologySuite
-
-
Cons
-
Requires a Postgres extension to be enabled in every environment (dev via Aspire, Testcontainers in CI, and any other runtime)
-
Additional EF Core package (
Npgsql.EntityFrameworkCore.PostgreSQL.NetTopologySuite) -
Operational complexity and migration noise not justified at current scale
-
Geocode on read (lazy)
Geocode each opportunity on demand when a list request needs coordinates.
-
Pros
-
No write-time dependency on the geocoder
-
-
Cons
-
Every read triggers external network I/O - slow and rate-limit-prone
-
Negates the point of caching coordinates
-
Domain event + background geocoder
Raise OpportunityCreated, handle it asynchronously, update coordinates later.
-
Pros
-
Write path is unaffected by geocoder availability
-
-
Cons
-
Eventual consistency for pin visibility complicates the UX
-
Extra moving parts (outbox / worker) not warranted yet
-
|
Note
|
Adopted after all, on 2026-08-18 - see the Amendment section at the end of this record. The "extra moving parts" already existed by then (the outbox shipped for #828); the eventual-consistency window this ADR worried about turned out to be the outbox’s own poll interval (seconds), not something worth rejecting the alternative over. |
Consequences
-
On-site opportunities created or updated while Nominatim is unreachable are persisted with null coordinates; they appear in the list but not on the map until
GeocodeVolunteerOpportunityAddressHandlerorGeocodingRetryJobfills them in - see the Amendment below; this is no longer conditioned on the organizer making a subsequent edit. -
Geocoding no longer adds request latency to create/update - see the Amendment below.
-
If the platform scales or expands beyond Germany, or dense geographic filtering becomes a hotspot, record the scaling risk as a TDR and, if triggered, capture the PostGIS adoption in a new ADR that supersedes this one.
Amendment (2026-08-18): Geocoding Moved to an Async Domain-Event Handler
The Decision above no longer matches shipped code. CreateVolunteerOpportunityCommandHandler and UpdateVolunteerOpportunityCommandHandler (via VolunteerOpportunity.Create / .Relocate) do not call IGeocodingService at all; they raise VolunteerOpportunityGeocodingRequestedDomainEvent, which the transactional outbox (ADR-adjacent to #828) dispatches to GeocodeVolunteerOpportunityAddressHandler (Application/VolunteerOpportunities/GeocodeVolunteerOpportunityAddress/v1/) - normally within the outbox’s own poll interval (5 seconds by default, Outbox:PollIntervalSeconds), not synchronously inside the write request. That handler calls IGeocodingService.GeocodeAsync, caches a non-transient result for 24h keyed on the address, and applies it via VolunteerOpportunity.ApplyGeocodingResult(…) on success or MarkAddressGeocodingFailed() on a definitive NotFound.
A separate GeocodingRetryJob (Infrastructure/BackgroundJobs/GeocodingRetryJob.cs) backstops the one case the handler above leaves unresolved: a TransientFailure outcome (Nominatim unreachable or throttled at that moment), which the handler logs and otherwise ignores rather than retrying inline. GeocodingRetryJob is an IHostedService on a PeriodicTimer firing every hour; each tick it re-queries every VolunteerOpportunity with Address.Latitude == null && !AddressGeocodingFailed && !IsRemote and retries IGeocodingService.GeocodeAsync for each, applying the same ApplyGeocodingResult / MarkAddressGeocodingFailed outcomes. It shares `NominatimGeocodingService’s process-wide throttle with every other Nominatim caller (ADR-5).
Net effect on this ADR’s own reasoning: the "Domain event + background geocoder" alternative in Considered Alternatives was adopted, not rejected. Its "eventual consistency complicates the UX" con did not materialize as feared, because the common path still resolves in the outbox’s single-digit-second poll interval; only a Nominatim outage or rate-limit hit waits on the hourly retry job. Its "extra moving parts" con was already paid for by the outbox existing for other domain events. Write-time-only geocoding, no PostGIS, and the bbox-plus-Haversine search strategy are all unaffected and remain this ADR’s decision.