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.
|
Names from inside the generator. This page names types from graphitron’s own classification model. They are accurate today, and worth knowing when a rejection message quotes one back at you, but they are not part of the authoring contract: the classification walk that produces them is being drained, and these names retire with it. What stays stable is the schema you write, the directive reference that describes it, and the diagnostics glossary. |
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 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.
Error-channel payload construction and @field(name:)
A class-backed payload with an errors: field is built by the generated fetcher in one of two shapes, and both honor @field(name:) on the payload’s SDL fields (the same directive the read side uses to name the accessor):
-
All-fields constructor (records, and POJOs with a single canonical constructor): the errors list is passed as the constructor parameter named by the errors-shaped field’s
@field(name:)value when present, and by SDL declaration position otherwise. A@field(name:)whose value matches no constructor parameter is rejected naming the value and the candidate parameters; a@field(name:)on the errors slot of a POJO compiled without-parameters(so parameter names are unavailable and the class is not a record) is rejected with guidance rather than silently falling back to position. -
Mutable bean (public no-arg constructor plus a Java-bean setter per SDL field): each setter is
set<UcFirst(base)>wherebaseis the field’s@field(name:)value when present, the SDL field name otherwise. Because a setter must exist for every SDL field, a data-field@field(name:)participates in the shape’s existence check: a payload whose setter matches the SDL name while the directive names a different member is rejected.
A present-but-blank @field(name: "") on any payload field is rejected on both shapes. When a rejected member resulted from a remap, the diagnostic carries a (remapped to '<value>' by @field) note so a failed override reads as an override.
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.deriveAccessorRecordParentSource).
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: a list child needs a list/set accessor; a single child needs a single-record accessor.
When all four hold, the classifier auto-derives an accessor-keyed batch key, the generator wires loader.load(key) (single-record accessor) or loader.loadMany(keys) (list/set accessor) 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 auto-derives the single-record accessor key; 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. -
Is the child resolved by a
@servicerather than by a join? Leave it class-backed and let the service signature name the key: theSourceselement type (Set<XRecord>) says which table the batch keys on, and the parent must be able to produce anXRecord. Accessor inference does not apply (it matches by the child field’s name, and the@servicekey is name-free), but the parent’s three producer routes do: be anXRecord, expose exactly one zero-arg accessor returning one, or declare@sourceRownaming a static method that returns one. On this path the directive returns theSourceselement record itself, not theRowNtuple it returns on a join-resolved child. See How-to: Handle services.
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). Input types are never table-bound (@tableon an input is a deprecated location and is ignored): an input is class-backed when a consuming method parameter reflects to a class, otherwise its fields resolve against each consuming field’s table. -
@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.