@record binds a GraphQL output or input type to an existing Java class. The class 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 or whether you need an @sourceRow. 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 (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 @record types follow the same shape with parallel permits on InputType (GraphitronType.java:282-340): JavaRecordInputType, PojoInputType, JooqRecordInputType, JooqTableRecordInputType. 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 @record(record: {className: "…FilmRecord"}) is the canonical shape:
type FilmDetails @record(record: {className: "no.sikt.graphitron.rewrite.test.jooq.tables.records.FilmRecord"}) {
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 @record(record: {className: "…FilmRecord"}) 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:
type CreateFilmsPayload @record(record: {className: "no.sikt.graphitron.rewrite.test.services.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:
type FilmCardWrapper @record(record: {className: "no.sikt.graphitron.rewrite.test.services.FilmCardData"}) {
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:
type CreateFilmPayload @record(record: {className: "no.sikt.graphitron.rewrite.test.services.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, switch from @record to @table and let the catalog drive the path.
Picking a variant: a quick decision tree
-
Is the type already a jOOQ table? Use
@table, not@record. 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?
@record(record: {className: "…"})with the wrapper class. The classifier picksJooqTableRecordType(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?
@recordwith the DTO class, 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
-
@recordand@tableon the same output type don’t co-occur. The type is either jOOQ-table-bound (TableType) or backed by a Java class (ResultType). Mixing them is a classification error. On input types@recorddominates@table: when both are present,@tableis silently shadowed and a build warning names the conflict; remove the@tabledeclaration to silence it. -
@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
@recordtypes feed straight into the resolver. The@servicereturn type is bound to the@recordclass, and the framework projects through it without an extra round trip.
See also
-
@recordis the parent-side directive that binds the backing class. -
@sourceRowsupplies the lift function when accessor inference can’t apply. -
@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
@service-@recordintegration 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.