A DataLoader-backed source-side field carries five orthogonal pieces of dispatch information: the per-row key shape, the key-lift provenance for record-backed parents, the loader container kind, the loader dispatch verb, and (per endpoint that owns one) a delivery arity. Each is a separate type axis in the model. Consumers read off whichever axis they actually fork on; no consumer reconstructs an axis by instanceof-ing a conflated permit.
This page is the chapter narrative for that contract. Reference detail (the per-arm record components, the per-axis enum/sealed values) lives on the source: javadoc on SourceKey, KeyLift, SourceKey.Wrap, SourceEnvelope, Arity, and LoaderRegistration.
The carriers live on the classified leaf model, which is the transitional producer surface of the strangler migration (see the Pipeline overview); the axis decomposition itself is the durable part. Each axis is an independent functional dependency of the coordinate, which is exactly the shape a fact relation stores, so the axes migrate as facts while the leaf carriers that hold them today drain.
The axes
Three classify-time model values carry the dispatch axes between them: a SourceKey per batch-keyed field (the key residue), a KeyLift on the batched re-query leaves (how the key tuple is lifted off the parent’s held object; total on the merged leaves), and a LoaderRegistration per field (DataLoader identity).
SourceKey is the batch key itself, nothing more:
record SourceKey(
List<ColumnRef> columns, // entry-point columns for the parent-input VALUES table
Wrap wrap // Row | Record | TableRecord(ClassName)
)
KeyLift, carried by BatchedTableField (both source shapes, lookup-keyed or not), and (as parentKeyLift) by the polymorphic InterfaceField / UnionField, names how the key tuple is lifted off the parent’s held object:
KeyLift
├─ FkColumns() ← project the key columns off the held jOOQ record, per column
├─ Lifter(LifterRef) ← @sourceRow static lifter (authored provenance)
├─ Accessor(AccessorRef, Arity) ← typed zero-arg accessor; arity = the accessor's return arity
└─ ProducedRecords(Arity) ← source=target re-fetch; arity = the produced wrapper
LoaderRegistration carries the loader identity:
record LoaderRegistration(
boolean valueIsList, // load(K)→V vs load(K)→List<V>
Container container, // POSITIONAL_LIST | MAPPED_SET
Dispatch dispatch // LOAD_ONE | LOAD_MANY
)
The values together describe one DataLoader-backed source side. Splitting them at these seams is what makes the boundary cases representable: a @service-backed field has a SourceKey and no KeyLift (its key extraction is the wrap-driven read the Sources signature authors); the carrier passthrough leaves (SingleRecordIdField, RecordCompositeField) carry a first-class SourceEnvelope and no LoaderRegistration (no DataLoader at all).
On the merged batched leaves the lift is total: a table-parent @splitQuery field carries KeyLift.FkColumns, a genuine member of the axis — the key tuple is projected off the held jOOQ record by column, exactly the mechanism FkColumns names on a record-backed parent. A total lift removes an absence case and tells no lie; that the Table arm’s emit path is still wrap-driven (GeneratorUtils.buildKeyExtraction) while only the constructor’s checkResidueAgreement consumes the stored lift today is deliberate provisioning for the unified fetcher, not a second meaning. The parent-backing fact itself is the leaf’s stored SourceShape — it cannot be recovered from the lift, since FkColumns is legitimately carried by both a table-row parent and a jOOQ-record-backed result parent.
Wrap: per-row key shape
SourceKey.Wrap is sealed: Row / Record / TableRecord(ClassName). The arm names the jOOQ type the DataLoader’s per-key value reads as.
Wrap
├─ Row() ← RowN<...> ; values only
├─ Record() ← RecordN<...> ; values + value1()..valueN()
└─ TableRecord(ClassName) ← typed jOOQ subclass ; e.g. FilmRecord
The TableRecord arm carries the developer-declared subtype as a payload because the column-tuple arms (Row, Record) have no use for it; an enum with a nullable recordClass field would be the conflated alternative. SourceKey.keyElementType() is total over the three arms without an extra nullable field on SourceKey itself.
The wrap is stored where it is authored, derived where it is inferred: the @splitQuery source-shape choice and the @service Sources signature store it on the residue directly; the record-sourced mints construct their residue through KeyLift.wrap(), a total derivation of the lift arm (FkColumns/Lifter/ProducedRecords pin Row; Accessor pins Record). Either way the batched leaves' compact constructors assert lift/residue agreement via KeyLift.checkResidueAgreement (the Table-sourced arm’s FkColumns/Row pairing satisfies it by construction).
KeyLift: how the key tuple is lifted off the held object
The four arms name how the batch key is lifted off the parent’s held object, and nothing else. FkColumns is the column-projection mechanism (a jOOQ record — the parent’s own table row on the Table-sourced arm, a record-backed result on the Record-sourced arm); the member-read arms (Lifter / Accessor / ProducedRecords) are class-backed-parent mechanisms, which is why the merged leaves' constructors pin sourceShape == Table ⟹ FkColumns. Where the key points is the leaf’s returnType.table() / ParentCorrelation; what shape the parent arrived in is the leaf’s stored SourceShape (and, for class-backed parents, the enclosing GraphitronType.ResultType); the two arity-bearing arms carry the Arity of the endpoint that owns it (the accessor’s return arity, the produced wrapper). Adding a new arm breaks the one exhaustive switch (GeneratorUtils.buildRecordParentKeyExtraction) at compile time until it is handled.
SourceEnvelope: carrier read-site fork
DIRECT vs OUTCOME_SUCCESS: whether a carrier data field reads env.getSource() verbatim or narrows the error-channel Outcome.Success first. Minted once per leaf at classification from the same structural signal (an errors-shaped payload field) that gives the producer its ErrorChannel.Mapped channel, and carried first-class on the two envelope-forking leaves (SingleRecordIdField, RecordCompositeField). The batched re-fetch path never carries it; there the generator derives the same fork at the type level as sourceIsOutcome.
Container + Dispatch: DataLoader identity
Two independent enums on LoaderRegistration:
-
Container:POSITIONAL_LIST(the loader is built withnewDataLoader; keys arrive asList<K>; returnsList<V>indexed 1:1 with keys) orMAPPED_SET(built withnewMappedDataLoader; keys arrive asSet<K>; returnsMap<K, V>). -
Dispatch:LOAD_ONE(one call toloader.load(key)returning one value) orLOAD_MANY(one call toloader.loadMany(keys)returning a list).
The two axes are independent. The accessor-many projection lands at POSITIONAL_LIST + LOAD_MANY: the loader is positional but each fetcher call uses loadMany because the parent record carries a list of accessor-projected keys. Conflating container and dispatch into a single axis would force a fourth synthetic combination ("positional but per-key list-valued") that nothing in the model corresponds to.
Cross-axis invariants
Cross-axis pairings that are structurally illegal are unrepresentable or rejected at the leaf constructors. The pipeline-tier tests pin SDL → emitted-shape end-to-end, and the cross-module compile against graphitron-sakila-example is the structural backstop. Three examples:
| Invariant | Why load-bearing |
|---|---|
|
The |
|
The rows-method body’s parent-input VALUES loop emits |
|
The carrier ID leaf reads node-key columns through typed |
Each invariant is one paragraph at a single place but governs the emit shape at multiple consumers downstream. Relaxing one without auditing the consumer side surfaces as a pipeline-test failure or a compile error in the generated graphitron-sakila-example source, not as a runtime surprise.
Consumer-side dispatch
Each emit site reads off whichever axis it actually forks on, without re-deriving the axis from a conflated identity:
-
GeneratorUtils.buildRecordParentKeyExtractionswitches exhaustively over the field’sKeyLiftto choose the parent-side extraction emit shape, then within the arity-bearing arms reads the arm’s ownArityto choose single-vs-list emit. Two axes, two reads; the dispatch is exhaustive over each axis independently. -
GeneratorUtils.buildKeyExtraction(for table-bound parents on the split-query and service paths) switches oversourceKey.wrap()to choose betweenDSL.row(…),parent.into(table.col, …), and a per-column copy onto a fresh typed record; one axis, one read. All three arms read the samesourceKey.columns(), so the axis picks the shape of the key and never its contents. -
DataLoaderFetcherEmitter.buildreadsregistration.container()to pickDataLoaderFactory.newDataLoadervsnewMappedDataLoader, andregistration.dispatch()to pickloader.load(key)vsloader.loadMany(keys). Two axes, two reads; the sameSourceKeycan be paired with either container. -
RowsMethodCall.batchLoaderLambdareadsregistration.container()to chooseList<K>vsSet<K>for the lambda’s keys parameter; one axis, one read. -
RootLauncherRenderer.renderframes every rows-method and root-launcher body, from a launcher command row. The per-shape skeleton that used to dispatch body framing on a permit projected from the field variant retired with the service fold, so the seam between body construction and outer-method framing is the command row itself;UnifiedEmissionPinsTestcounts the render call sites that keep it there. -
FetcherEmitter’s carrier leaves fork on the leaf’s `SourceEnvelopecomponent to narrowOutcome.Successbefore the read; the bulk arrival is the field’s own wrapper position (returnType().wrapper().isList()), not a stored count.
The pattern: each consumer’s switch is exhaustive over exactly one axis, and the compiler enforces that adding a new arm to any axis breaks every consumer whose dispatch isn’t yet aware of it.
Connection to the principle
This is the live worked example for Sealed hierarchies over enums for typed information. The axis split is the principle in action: each axis is a sealed sub-hierarchy or enum carrying exactly the information its consumers need, and the compiler enforces exhaustive switches at every dispatch site. The smell the principle warns about (a single shared accessor whose meaning depends on the variant, or a permit name that splices two axes together) is the alternative this model rejects by construction; the decomposition (which deleted the conflated target/path/cardinality/reader components this chapter used to narrate) is the same principle applied to the key itself.