A DataLoader-backed source-side field carries five orthogonal pieces of dispatch information: the per-row key shape, the body-input contract for the rows-method, the per-source row count, the loader container kind, and the loader dispatch verb. 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, SourceKey.Reader, SourceKey.Wrap, and LoaderRegistration.
The axes
Three model values carry the dispatch axes between them: a SourceKey per field (singular, classify-time), zero or more SourceRow instances per fetch (runtime, the data flowing through), and a LoaderRegistration per field (singular, classify-time, DataLoader identity).
SourceKey carries four of the axes:
record SourceKey(
TableRef target, // join target (or null for the parent-IS-source case)
List<ColumnRef> columns, // entry-point columns (parent-side or target-side per path)
List<JoinStep> path, // empty = target-aligned; non-empty = FK chain to target
Wrap wrap, // Row | Record | TableRecord(ClassName)
Cardinality cardinality, // ONE | MANY
Reader reader // ColumnRead | AccessorCall | SourceRowsCall | …
)
LoaderRegistration carries the remaining two:
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 two values together describe one DataLoader-backed source side. Splitting them at this seam is what makes the rooted-DML data-field case representable: when the data fetcher reads env.getSource() directly instead of going through a loader, the field has a SourceKey and no LoaderRegistration. If the container and dispatch axes lived on SourceKey, the rooted case would need a vestigial LoaderRegistration slot or a fork in SourceKey.
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.
Reader: body input contract
SourceKey.Reader is sealed with five arms; each one names what the rows-method body reads to produce its output. SQL-side bodies read parent-side data; service-side bodies read the service-return shape.
Reader
├─ ColumnRead() ← FK columns on the parent record (catalog FK)
├─ AccessorCall(AccessorRef) ← typed zero-arg accessor on a @record parent
├─ SourceRowsCall(LifterRef) ← @sourceRow static lifter on a @record parent
├─ ServiceTableRecord(ClassName) ← @service returning a typed TableRecord subclass
└─ ServiceUntypedRecord() ← @service returning Record<> / scalar
Reader is the body’s input contract, not "where the data comes from"; the body emitter reads the contract to know what code to emit, not to recover the directive that produced the field. Adding a new arm (for example, walking a Result<RecordN> from upstream DML when the rooted-DML data-field path lands) is a one-arm addition to this enum; every consumer’s exhaustive switch breaks at compile time until the new arm is handled.
Cardinality: rows per key
A two-arm enum: ONE (one source row per key — catalog FK, accessor-single, service target-aligned) or MANY (list-valued source, accessor-many, list-valued service). Drives the rows-method body’s per-key iteration shape and the loader’s load vs loadMany choice.
Cardinality lives on SourceKey rather than LoaderRegistration because the per-source row count is a property of the source-side data shape, not of the DataLoader’s identity. The same SourceKey.MANY can be loaded into either container (positional or mapped); the cardinality fixes the body’s iteration, the container fixes the loader’s framing.
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 AccessorKeyedMany 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
Three pairings are structurally illegal; SourceKey’s compact constructor rejects them. The pipeline-tier tests pin SDL → emitted-shape end-to-end, and the cross-module compile against `graphitron-sakila-example is the structural backstop.
| Invariant | Why load-bearing |
|---|---|
|
The |
|
The rows-method body’s parent-input VALUES loop emits |
|
When the service returns a typed |
Each invariant is one paragraph at a single place (the compact constructor) 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 oversourceKey.reader()to choose the parent-side extraction emit shape, then within theAccessorCallarm readssourceKey.cardinality()to 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, …), andparent.into(Tables.X); one axis, one read. -
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. -
RowsMethodSkeleton.builddispatches the body framing on theRowsMethodBodypermit, which the caller projected from(sourceKey.reader(), registration.container()). The skeleton’s exhaustive switch over fiveRowsMethodBodypermits is the seam between body construction (per-shape) and outer-method framing (uniform across permits).
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 four-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.