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 IGeocodingService synchronously; they raise a domain event handled asynchronously. The rest of this Decision (write-time only, no PostGIS, bbox + Haversine for search) is still accurate.

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_longitude columns (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 GeocodeVolunteerOpportunityAddressHandler or GeocodingRetryJob fills 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.