Skip to content

Idea: declarative VO→domain mapping — the hand-written mapper is where invisible defaults hide #199

Description

@dmealing

Context

The extract route (MetaObjectExtractor.extractExtractionResult(data, report)) does its job well: it parses a forgiving/LLM-emitted document against declared metadata, produces a typed VO, and classifies everything it could not populate (LOST_REQUIRED / LOST_OPTIONAL / MALFORMED / DEFAULTED) in the ExtractionReport.

But the VO is not the application's domain object. A consumer typically has a hand-written mapper in between:

LLM/wire document → [metadata + lenient reader] → typed VO → [HAND-WRITTEN MAPPER] → domain object
                     ^^^^^^^^^^^^^^^^^^^^^^^^^                 ^^^^^^^^^^^^^^^^^^^^
                     declared, reported                        undeclared, unreported

Everything to the left of that mapper is modelled and observable. Everything inside it is invisible Java.

The problem this causes (a real incident)

A required boolean was absent from a malformed response. The lenient reader did exactly the right thing — it recorded the field as lost. Then the hand-written mapper did:

boolean flag = Boolean.TRUE.equals(vo.isSomeFlag());   // absent → null → false

…and the loss became indistinguishable from a real answer. No exception, no warning, a healthy-looking log line. It went undetected across an entire test run because nothing read the report and nothing declared the field required.

The framework had already caught it. The hand-written layer un-caught it.

What that layer actually contains

Measured across three mappers in one consumer (~2,300 lines total):

category count expressible in metadata today?
hand-coded literal defaults (x != null ? x : LITERAL, Boolean.TRUE.equals(x), blankToDefault(x, "…")) 37 mostly YES — this is @default, which already exists and the consumer simply wasn't using
drop-the-entry-and-warn invariants (continue; + log.warn) 48 no
id → entity resolution (short id like ENT-001 → persistent key, via a session-scoped lookup) 20 no — genuinely needs runtime context
field renames / target-type construction throughout no

So a large slice is a consumer bug (not using @default), and I'd fix that on my side regardless. But the remainder is a real modelling gap.

The idea

A declared VO → domain mapping (map.* / object.mapping / whatever fits the vocabulary), so the last undeclared hop becomes metadata like every other hop:

  1. target type + field renames — the mechanical part.
  2. absent-value policy — reuse the existing @default / @required rather than inventing a second mechanism.
  3. drop policy — declare "an entry missing its identity is dropped" instead of hand-writing continue + a log line, and have the drop appear in the ExtractionReport so it is auditable like every other loss.
  4. resolver hook — the one thing that inherently needs runtime context. Declared in the metadata, implemented by the consumer (interface Resolver<K, V>), so that "this short id resolves to that entity" is modelled even though the lookup is not.

The prize is that the report becomes the single, complete account of what survived the wire — today it is only an account of what survived the parser, and a hand-written mapper downstream can quietly contradict it.

A tension worth surfacing regardless of this idea

Extract.java fills a field carrying @default on absence and classifies it DEFAULTED, which satisfies required. So a field can never be both "defaulted when missing" and "reported when missing" — declaring a default silently disables loss detection for that field.

That is defensible (a default is an answer), but it is a sharp edge: adding an innocuous @default to a shared/abstract enum can silently turn off LOST_REQUIRED for every field that extends it, and nothing warns you. Worth documenting explicitly; and if a declared mapping lands, it should make the choice explicit rather than implicit.

Status

Idea / discussion — not blocking anything. Filing so the thinking isn't lost. Happy to prototype the shape against a real consumer if the direction is interesting.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions