Skip to content

Address value objects — three independent axes, one entity

An address looks like one thing — a few lines of text — but a system that acts on addresses needs three separate answers, and they don’t move together:

  1. What did the user type? The postal address: street, city, postal code, country. The input fact.
  2. Where is it on a map? The geocoding outcome: a coordinate and how precise the match was — or that no coordinate could be found.
  3. Is it real and deliverable? The verification verdict: did an authoritative source, a human, or an actual delivery confirm it — or reject it.

These are orthogonal. A brand-new rural address can fail to geocode (no OpenStreetMap node for the lane) yet be 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 at it. Collapsing the three into one “is the address good?” boolean throws away exactly the distinctions that drive real decisions — whether to plot a marker, whether to ship, whether to ask the user to re-check.

Granit models them as three mutualized value objects in the base Granit package (Granit.Domain.ValueObjects), so every address-holding entity across the framework and the business modules stores the same shapes the same way:

AxisValue objectAnswers
PostalAddressThe typed address + its delivery-point kind
GeocodingAddressGeocodingCoordinate, match precision, freshness
VerificationAddressVerificationVerdict + the evidence behind it

Address is a ValueObject built through a validating factory; its Country is normalised to upper-case ISO 3166-1 alpha-2 on construction.

public static Address Create(
string street1,
string city,
string postalCode,
string country,
string? street2 = null,
string? state = null,
AddressDeliveryPointType? deliveryPointType = null);
MemberTypeNotes
Street1stringPrimary line. Personal data.
Street2string?Optional second line. Personal data.
CitystringLocality.
PostalCodestringPersonal data.
Statestring?Region / province, optional.
CountrystringISO 3166-1 alpha-2, upper-cased.
DeliveryPointTypeAddressDeliveryPointType?Street, PO box, or other.

AddressDeliveryPointType distinguishes a delivery point a courier can reach from one that only takes mail — a distinction geocoding alone can’t make:

ValueMeaning
StreetA regular street address.
PoBoxA post-office box — postal mail only, no courier delivery.
OtherAny other delivery point (military, poste restante, …).

AddressGeocoding records the result of placing the address on a map. Its Status is the headline; the coordinate and precision fill in the detail.

public static AddressGeocoding Pending { get; } // never attempted
public static AddressGeocoding Resolved(GeoCoordinate coordinate,
GeocodeMatchPrecision precision, DateTimeOffset geocodedAt,
string? houseNumber = null, string? poBox = null); // matched
public static AddressGeocoding Failed(DateTimeOffset attemptedAt); // no match
public AddressGeocoding AsStale(); // address changed since

AddressGeocodingStatus is a small, deliberate state machine:

StatusMeaning
PendingAddress attached, never geocoded — awaiting a first attempt.
ResolvedResolved to a precise coordinate (rooftop or street-level match).
ApproximateResolved, but only to a locality / postcode centroid — coarse.
FailedNo match found (a typo, or a gap in the provider’s coverage). Not the same as invalid.
StaleThe address changed since the last successful geocoding — to be re-resolved.

When Resolved, MatchPrecision says how precise the hit was:

GeocodeMatchPrecisionMeaning
RooftopMatched to an exact building / house number.
StreetMatched to a street (interpolated along the road).
LocalityMatched only to a locality / postcode centroid.

Coordinate is exposed as a computed GeoCoordinate? (the persisted source of truth is the scalar Latitude / Longitude pair; the GeoCoordinate projection is [JsonIgnore] and not stored). Failed is a normal outcome, not an exception — an unresolvable address is data, not an error.

AddressVerification — the deliverability verdict

Section titled “AddressVerification — the deliverability verdict”

AddressVerification records whether the address is real and reachable, and — crucially — on what evidence. The verdict and its source are stored together so a DeliveryConfirmed from a real courier run is never confused with a weak geocoding-plausibility guess.

public static AddressVerification Unverified { get; }
public static AddressVerification Create(
AddressVerificationStatus status,
AddressVerificationSource source,
DateTimeOffset verifiedAt,
string? verifiedBy = null,
string? evidence = null);
AddressVerificationStatusMeaning
UnverifiedNo verification evidence yet.
ProviderVerifiedConfirmed by an authoritative verification provider (DPV / RDI).
CorrectedConfirmed by a provider, which standardised / corrected the input.
ManuallyConfirmedConfirmed manually by an operator.
DeliveryConfirmedConfirmed by a real-world positive outcome (successful courier delivery or postal mail).
InvalidKnown bad — a provider rejected it, or a delivery / mailing came back undeliverable.

The AddressVerificationSource records the provenance that produced the verdict, ordered roughly by strength:

AddressVerificationSourceMeaning
NoneNo source (the default for Unverified).
GeocodingInferred from geocoding plausibility — weak (existence, not deliverability).
VerificationProviderAn authoritative address-verification provider.
ManualA human operator confirmed it.
DeliveryA successful courier delivery to the address.
PostalMailA successful postal mailing (non-returned mail).

These three sources map onto the platform’s three evidence tiers: Geocoding is tier 0, VerificationProvider is tier 1, and Manual / Delivery / PostalMail are the tier-2 real-world evidence recorded by a consuming module.

GeoCoordinate — a validated WGS 84 point

Section titled “GeoCoordinate — a validated WGS 84 point”

Both axes lean on GeoCoordinate, a value object that cannot hold an invalid point — the constructor throws on out-of-range latitude/longitude, and TryCreate offers an allocation-free, exception-free alternative.

public GeoCoordinate(double latitude, double longitude); // throws if out of range
public static bool IsValid(double latitude, double longitude);
public static GeoCoordinate? TryCreate(double latitude, double longitude);

Latitude is constrained to [-90, 90], Longitude to [-180, 180].

Geocoding and verification are only useful if you can filter and group by them: “show every customer whose address is Failed”, “count parties DeliveryConfirmed this quarter”, “find the Approximate-only addresses a human should re-check”. A JSON blob would bury those columns; an opaque value-converter would make them un-queryable (see ADR-070).

So the EF helpers in Granit.Persistence.EntityFrameworkCore map each VO as an EF ComplexProperty — flattened to real, indexable scalar columns, with enums stored as their PascalCase string names (ADR-059):

modelBuilder.Entity<Customer>()
.MapAddressGeocoding(c => c.Geocoding)
.MapAddressVerification(c => c.Verification);
HelperProduces (flat columns)
MapAddressGeocodingLatitude, Longitude, Status (string), MatchPrecision (string), GeocodedAt, HouseNumber, PoBox. The computed Coordinate is ignored.
MapAddressVerificationStatus (string), Source (string), VerifiedAt, VerifiedBy, Evidence.

Because the status and precision land in their own string columns, a dashboard query group-by’ing on Status or a filter for MatchPrecision = 'Locality' translates straight to SQL — no JSON path, no projection step. This is exactly the ComplexProperty opt-in strategy ADR-070 prescribes for VO columns you need to query.

Privacy — the coordinate is referential PII

Section titled “Privacy — the coordinate is referential PII”

A coordinate that pins where a named person lives is personal data, and the sensitive fields are marked as such: Latitude, Longitude, HouseNumber, PoBox on AddressGeocoding, and VerifiedBy / Evidence on AddressVerification, all carry [SensitiveData(Level = Sensitivity.Confidential)] (as do Address.Street1 / Street2 / PostalCode).