A class-backed GraphQL output or input type is bound to an existing Java class. The binding is derived by reflection from the type’s producer: for an output type, the producing field’s return type (a @service method return or a parent-accessor chain); for an input type, the method parameter the input flows into. The reflected class’s shape determines which sealed result-type variant the rewrite picks and, for child @table fields on free-form parents, whether the classifier can auto-derive a batch key from a typed accessor. This recipe walks the decision tree and the operational consequences for each variant.
The four output variants
The sealed GraphitronType.ResultType hierarchy has four permits (GraphitronType.java:89-141). The classifier picks among them by reflecting on the backing class the producer yields (TypeBuilder.buildResultType:513-546):
-
JooqTableRecordType: backing class extendsorg.jooq.TableRecord. The catalog supplies FK metadata; child@tablefields use@referencepaths driven by the catalog, and@sourceRowis rejected (the path is already known). -
JooqRecordType: backing class extendsorg.jooq.Recordbut notTableRecord. Same FK availability as table-records via the carried row type; the variant exists because a genericRecordisn’t bound to one canonical table. -
JavaRecordType: backing class is a Javarecord(cls.isRecord()). The catalog has no FK metadata for the hand-written class. Child@tablefields fall back to either accessor inference (a record component returning a typed jOOQTableRecord) or@sourceRow. -
PojoResultType: anything else (plain POJO, DTO, hand-rolled class with setters). Same accessor-vs-lifter contract asJavaRecordType.
The classification check order is isRecord → TableRecord → Record → fallback to PojoResultType. TableRecord is checked before Record because every TableRecord is also a Record; the more specific match wins.
Input-side class-backed types follow the same shape with parallel permits on InputType (GraphitronType.java:282-340): JavaRecordInputType, PojoInputType, JooqRecordInputType, JooqTableRecordInputType. The backing class is reflected from the @service / @condition / @tableMethod method parameter the input flows into. The Maven plugin can scaffold a Java record matching the input’s field shape; binding flows through setters or the canonical constructor.
jOOQ-record parents: catalog drives everything
When the parent is a JooqTableRecordType, child @table fields traverse via @reference paths the catalog already knows about. The example schema’s FilmDetails is the canonical shape; its producer’s return type reflects to the jOOQ FilmRecord:
# backed by no.sikt.graphitron.rewrite.test.jooq.tables.records.FilmRecord, reflected from its producing field's return type
type FilmDetails {
title: String! @field(name: "title")
language: [Language!]! @reference(path: [{key: "film_language_id_fkey"}])
}
Because FilmRecord is a jOOQ TableRecord, the rewrite reads film_language_id_fkey from the catalog and emits a column-keyed DataLoader for the language child. @sourceRow would be redundant here and is rejected at classify time. @splitQuery is also unnecessary on these fields: classifyChildFieldOnResultType never inspects it on record-parent table-bound fields, which are DataLoader-batched unconditionally.
Smallest jOOQ-record fixture: FilmCard (backed by FilmRecord, reflected from its producer) with a single scalar projection filmId: Int @field(name: "film_id"). Read-paths into and out of FilmCard flow through FilmRecord instances; only the PK is set when the parent is constructed via @externalField's Field<FilmRecord> shape, and other columns are batch-fetched on demand.
Accessor inference on free-form parents
When the parent is a JavaRecordType or PojoResultType and a child returns a @table type, the classifier introspects the backing class for a typed accessor: an instance method that takes no arguments and returns either a jOOQ TableRecord subclass (single-cardinality) or a List<TableRecord> / Set<TableRecord> (list-cardinality), where the TableRecord element type matches the child field’s @table return (FieldBuilder.deriveBatchKeyFromTypedAccessor:2817-2934).
Matching rules:
-
Instance method only — no static, bridge, or synthetic.
-
Zero parameters.
-
Method name matches the GraphQL field name as
fieldName,getFieldName, orisFieldName. -
Return type peels through
List<…>/Set<…>(for list cardinality) and the element type must be assignable to the child’s@tablereturn’s record class. -
Cardinality alignment: list child needs list/set accessor (
AccessorKeyedMany); single child needs single-record accessor (AccessorKeyedSingle).
When all four hold, the classifier emits BatchKey.AccessorKeyedSingle or BatchKey.AccessorKeyedMany, the generator wires loader.load(key) or loader.loadMany(keys) against the element table’s PK, and no directive on the child is needed.
The example schema’s CreateFilmsPayload exercises the Many shape. Its @service producer returns the backing class, so reflection binds it:
# backed by no.sikt.graphitron.rewrite.test.services.CreateFilmsPayload via its @service producer's return type
type CreateFilmsPayload {
films: [Film!]!
}
Backing class:
public record CreateFilmsPayload(List<FilmRecord> films) {}
The canonical record-component accessor films(): List<FilmRecord> is what the classifier picks up. No @sourceRow, no @reference; per request the framework gathers all parents' film keys and dispatches one batched lookup keyed on film.film_id.
The Single shape lives at FilmCardWrapper:
# backed by no.sikt.graphitron.rewrite.test.services.FilmCardData, reflected from its producing field's return type
type FilmCardWrapper {
film: Film
}
public record FilmCardData(FilmRecord film) {}
The film() accessor returns one FilmRecord matching the child field’s Film table. The classifier emits AccessorKeyedSingle; per request, all parents' single keys batch through loader.load, returning one Film row per distinct PK.
When to reach for @sourceRow
Accessor inference works only when the backing class already carries a typed jOOQ record. If the parent only has the FK column as a primitive scalar (e.g., Integer languageId rather than LanguageRecord language), there is nothing for the classifier to reflect on and accessor inference fails. That’s the @sourceRow case.
The example schema’s CreateFilmPayload is the canonical shape; its @service producer returns the backing class:
# backed by no.sikt.graphitron.rewrite.test.services.CreateFilmPayload via its @service producer's return type
type CreateFilmPayload {
languageId: Int!
language: [Language!]!
@sourceRow(
className: "no.sikt.graphitron.rewrite.test.services.CreateFilmPayloadLifter",
method: "liftLanguageId"
)
}
Backing class and lifter:
public record CreateFilmPayload(Integer languageId) {}
public final class CreateFilmPayloadLifter {
public static Row1<Integer> liftLanguageId(CreateFilmPayload p) {
return DSL.row(p.languageId());
}
}
Per request the framework calls liftLanguageId once per parent, gathers the resulting Row1<Integer> keys, and dispatches a single language_id IN (…) batch. The lifter’s Row1<Integer> matches the leaf table’s primary key (language.language_id) by default; the per-position Java type is checked against the column’s column class at build time.
For multi-hop paths, compose with @reference: the lifter’s RowN then matches the first FK hop’s source-side columns rather than the leaf PK. Multi-column lifts use Row2..RowN (ValuesJoinRowBuilder.MAX_ARITY = 22 is the upper bound). A mismatch between the lifter’s arity and the derived parent-side tuple fails the build with the offending arity in the message.
Diagnostic when neither path is available
A bare child @table reference on a JavaRecordType or PojoResultType parent — no accessor matching the field name, no @sourceRow — fails classification with:
on a free-form DTO parent requires a typed accessor or @sourceRow to lift the batch key; the catalog has no FK metadata for the parent class. Either expose a typed accessor on the parent returningList<…Record>,Set<…Record>, or…Record(where…Recordis the element type’s jOOQ TableRecord); or add@sourceRow(className: …, method: …)optionally composed with@reference; or back the parent with a typed jOOQ TableRecord so the FK can be derived.
(FieldBuilder.java:2765.)
The diagnostic is the entry point to the decision tree: pick whichever route fits the parent’s shape best. If the backing class already carries a typed TableRecord (or List<TableRecord>), expose it as a public accessor matching the child field’s name and inference takes over. If the parent only carries scalar FK columns, write a @sourceRow. If the parent is genuinely table-backed, add @table so the type is table-bound and let the catalog drive the path.
Picking a variant: a quick decision tree
The producer’s return type determines the backing class. The only authoring choice is whether the type is table-bound or class-backed: add @table if the type is a jOOQ table, otherwise leave it class-backed and the rewrite reflects the producer’s return type to pick the variant.
-
Is the type already a jOOQ table? Add
@table. Catalog-driven joins and column projection apply directly; you avoid the per-field lifting question. -
Is the type a hand-rolled wrapper around one or more jOOQ records? Leave it class-backed; the producer returns the wrapper class. The classifier picks
JooqTableRecordType(if the class extendsTableRecord),JooqRecordType(genericRecord), orJavaRecordType/PojoResultTypefor hand-rolled classes; child@tablefields use accessor inference when the wrapper exposes typed accessors. -
Is the type a service-result DTO with only scalar fields? Leave it class-backed (the producer returns the DTO), plus
@sourceRowon each child@tablefield. The lifter extracts the FK column(s) the DTO carries and feeds them into the batched join. -
Mixing the two on the same parent? Yes; different children can mix accessor inference and
@sourceRowon the same parent class, because the per-field classification runs once per child. The same backing class can carry typed accessors for some fields and primitive scalars (lifted into batch keys) for others.
Constraints and gotchas
-
Table-bound and class-backed are exclusive on an output type. The type is either jOOQ-table-bound (
TableType, via@table) or backed by a Java class reflected from its producer (ResultType). On input types,@tablewins over a reflected class binding: when both apply, a build warning names the conflict. -
@sourceRowis rejected onJooqTableRecordTypeandJooqRecordTypeparents. The catalog already supplies the path; use@reference. The diagnostic names the directive that’s redundant. -
Accessor inference rejects bridge and synthetic methods. If a backing class has a generic-typed accessor that the compiler erases through a bridge method, the bridge is filtered out before name matching. The user-visible accessor (the canonical declared method) is what the classifier sees.
-
@splitQueryon record-parent table-bound fields is redundant but not rejected.classifyChildFieldOnResultTypenever inspects the directive on these fields; record-parent children are DataLoader-batched unconditionally. -
Service methods returning a class-backed type feed straight into the resolver. The
@servicereturn type is the binding, and the framework projects through it without an extra round trip.
See also
-
@referenceis the catalog-driven counterpart on@tableand jOOQ-record parents. -
@tableis the catalog-bound alternative when the type is the table. -
How-to: Wire external Java code covers the broader
@serviceintegration patterns. -
How-to: When to split queries covers the inline-vs-split distinction; record-parent children sit in the always-batched path and are exempt from the inline shape.