@service hands the database operation for a field off to a custom Java method. The reference page covers signature, the argMapping: syntax, and the constraints. This recipe addresses the three operational topics it defers: how arguments (including nested input types and request-context values) flow into Java parameters, what return shapes the framework recognises and how to choose among them, and the contract the rewrite imposes when @service lands on a non-root field.
For the surrounding context (when to reach for @service vs @mutation) see the reference’s "Constraints" section. This recipe assumes the choice is already made.
Argument binding: from GraphQL to Java parameters
The framework constructs the service class with whatever it can resolve from the request context (a DSLContext for the request’s database session is the most common parameter), then calls the named method. Each remaining Java parameter is supplied from one of three sources:
-
A GraphQL argument, by name. The Java parameter
idsmatches the GraphQL argumentids: [Int!]!. -
An
argMapping:rebinding.argMapping: "filmIds: ids"means "the Java parameterfilmIdsreceives the value of the GraphQL argument `ids`". Multiple entries are comma-separated; whitespace is tolerated. -
A request-context value, when the parameter name matches an entry in
contextArguments:. Each named value is pulled from the requestGraphQLContextper request and threaded into the call as an additional argument.
The example schema’s three root services exercise the first two sources:
type Query {
filmsByService(ids: [Int!]!): [Film!]!
@service(service: {
className: "no.sikt.graphitron.rewrite.test.services.SampleQueryService",
method: "filmsByService"
})
filmsByServiceRenamed(ids: [Int!]!): [Film!]!
@service(service: {
className: "no.sikt.graphitron.rewrite.test.services.SampleQueryService",
method: "filmsByServiceRenamed",
argMapping: "filmIds: ids"
})
}
The Java surface:
public static Result<FilmRecord> filmsByService(DSLContext dsl, List<Integer> ids) { ... }
public static Result<FilmRecord> filmsByServiceRenamed(DSLContext dsl, List<Integer> filmIds) { ... }
Both bind the [Int!]! argument to the method’s List<Integer> parameter; the rename version reads more naturally on the Java side because filmIds is unambiguous in a class that handles many entity types. Use argMapping: when the Java method’s signature already exists with a name you cannot change (e.g., a shared service class) or when the GraphQL argument’s name is opaque (e.g. id on an interface) and the Java side wants more specificity.
Nested input types
When a field takes an input-type argument, the framework passes the input through as a single Java parameter, not flattened into per-field parameters. A field declared as
createFilm(in: FilmCreateInput!): Film
@service(service: {className: "...", method: "createFilm"})
binds to
public static FilmRecord createFilm(DSLContext dsl, FilmCreateInput in) { ... }
where FilmCreateInput is a Java class whose components correspond to the input-type’s fields. The backing class is reflected from the createFilm method’s parameter type; graphql-java does the input deserialisation; the framework just hands the resulting object to the method. The Java side reads in.title(), in.languageId(), etc.
When the input type’s producer (the service method parameter) is a typed Java class, the backing class is the canonical home for the input shape. When the input flows into no typed parameter, the framework’s input projection produces an ImmutableMap<String, Object> that the Java method must read by key; the typed-class shape is strongly preferred, so declare the service method’s parameter as the typed input class.
argMapping: works for input arguments the same way it works for scalars: argMapping: "input: in" rebinds the Java parameter name.
It can also reach inside the input: the right-hand side of an entry may walk into nested input fields with dot-separated segments, so a method that wants two scalars out of a wrapper input can have them without the wrapper.
type Mutation {
createFilm(in: FilmCreateInput!): Film
@service(service: {
className: "com.example.services.FilmService",
method: "createFilm",
argMapping: "title: in.title, languageId: in.languageId"
})
}
Binding a parameter to a nested input field is the canonical description of the path form. What stays outside @service’s surface is renaming the fields of the input type as seen by its backing class; that is the input type’s own concern (see @field` on input fields).
Note the axis argMapping: works on: it rebinds method parameters, scattering one input’s fields across several of them. Grouping input fields under a nested type, below, works on the input’s members instead: one bean parameter, whose fields are reached through the nesting. The two do not overlap.
Grouping input fields under a nested input type
An input type’s fields can be clustered under nested input objects for the client’s benefit while the backing Java class stays flat. A nested input field with no matching member on the backing class is treated as a grouping: its own fields bind against the backing class as if they had been declared at the top level of the enclosing input.
input FilmCreateInput {
title: String!
duration: FilmDurationInput
}
input FilmDurationInput {
length: Int
rentalDays: Int
}
public record FilmCreateInput(String title, Integer length, Integer rentalDays) {}
The client sends duration: { length: 120 }; the method receives length = 120. The grouping is wire-format ergonomics only and has no effect on the Java side.
A nested input field whose name does match a member of the backing class keeps binding to that member as a nested object, so adding a matching member is how you opt a group back out of flattening. A group that is absent or null leaves every field under it null, the same as omitting those fields would.
Rejected shapes, each named at build time: a grouping input that reaches itself; a list-shaped grouping input (there is nothing for a list of groups to flatten onto); two fields that bind to the same member, whether or not a group is involved; and a nested input field carrying @field(name:) or @nodeId whose named member does not exist, which is reported as the missing member rather than silently flattened.
A jOOQ record as the parameter: binding on the column axis
When the service method’s parameter is a jOOQ-generated TableRecord rather than a hand-written class, the input binds on the column axis instead of the Java-member axis: each plain input field names a column with @field(name:), and a @nodeId field decodes an identity into the record’s key columns. The framework builds the record for you and hands it to the method already populated.
input FilmUpdateInput {
filmId: ID! @nodeId(typeName: "Film")
title: String @field(name: "title")
releaseYear: Int @field(name: "release_year")
}
public static String updateFilm(DSLContext dsl, FilmRecord in) { ... }
Each field loads independently and only when the client actually sent it, so jOOQ’s per-column changed flag stays honest: an omitted field leaves its column untouched (and out of the INSERT / UPDATE your service runs), an explicitly-sent null writes SQL NULL.
Renaming a field: two names, one column
Renaming a published field follows the usual deprecation pattern, and it works here without touching the database: add the new field, keep the old one marked @deprecated until its removal date, and point both at the same column.
input FilmUpdateInput {
filmId: ID! @nodeId(typeName: "Film")
releaseYear: Int @field(name: "release_year")
year: Int @field(name: "release_year")
@deprecated(reason: "renamed to releaseYear; removed no earlier than 2028-03-31")
}
The two names resolve to one write, in a stated precedence order: the live (non-deprecated) field wins, and where every name on a column is deprecated (a rename chain part-way to removal) the latest-declared one wins. So a client sending only year still writes; a client sending both gets releaseYear; a client sending neither leaves the column untouched. While one name is still live, reordering the two fields in the schema does not change the outcome; only in the all-deprecated case does declaration order decide.
Two live fields on one column are still an error, named at build time: without a @deprecated marker there is nothing saying which of them the author meant to win. The fix is to remove one, point its @field(name:) at a different column, or mark the superseded one @deprecated to declare it an alias.
This applies to a jOOQ record @service parameter. Graphitron-owned @mutation inputs (INSERT / UPDATE) still reject two plain fields on one column: their write goes through a SET map that holds one value per column, and the bulk UPDATE path cannot name one derived column twice.
|
contextArguments: pulling from request state
contextArguments: is for values that aren’t GraphQL arguments but live on the per-request GraphQLContext: tenant IDs, authenticated principals, feature-flag snapshots. Declaring contextArguments: ["tenantId"] in the directive means the framework looks up env.getGraphQlContext().get("tenantId") and threads the result as an additional Java parameter named tenantId:
filmsForTenant(genre: String): [Film!]!
@service(
service: {className: "...", method: "filmsForTenant"},
contextArguments: ["tenantId"]
)
public static Result<FilmRecord> filmsForTenant(DSLContext dsl, String tenantId, String genre) { ... }
The runtime contract is "your servlet or webfilter must place each named value on the request context before the resolver runs". The framework does not validate context arguments at startup; a missing context value yields null at call time, which most code will then throw on. The classifier does check that the parameter exists on the Java method’s signature.
Response shapes the framework recognises
A @service method is opaque to the generator: no SQL is spliced, no projection is grafted on. The classifier inspects the return type and routes the result through one of four runtime paths.
Result<TableRecord> (or List<TableRecord>): the records are key carriers
public static Result<FilmRecord> filmsByService(DSLContext dsl, List<Integer> ids) {
return dsl.select(Tables.FILM.FILM_ID)
.from(Tables.FILM)
.where(Tables.FILM.FILM_ID.in(ids))
.orderBy(Tables.FILM.FILM_ID)
.fetchInto(Tables.FILM);
}
When the return type is bound to a @table GraphQL type, the framework treats the returned records as key carriers: it lifts each record’s primary key and re-selects the fields the query asked for from the table, in one batched query on the request’s connection, ordered back to the order your method returned. Populate the key columns; everything else comes from the table. This is the same contract for every table-returning service shape (plain, interface, union), and it is the outbound mirror of the inbound rule above: records crossing the service boundary carry the key columns and nothing else, in both directions.
So the example selects FILM_ID alone and is a complete answer for any selection set over Film. Selecting more columns is harmless, and existing services that select every column keep returning the same rows; they just pay one keyed SELECT they no longer need. Rewriting them to select keys only removes that cost.
Two consequences worth knowing before you rely on this:
-
The returned table needs a primary key. There is nothing else to key the re-select on, so a
@tabletype over a key-less table is rejected at build time, naming the table. -
A key with no live row drops out. If your method hands back a key the table has no row for, that element is absent from a list result, and a single result resolves
null. This is worth a second look on a mutation@servicethat deletes rows and hands the deleted records back: those rows are gone, so there is nothing to re-select. Return the ids, or use the escape hatch below.
Escape hatch: a type without @table
@table on a GraphQL type is the declaration that the type is that table’s rows, which is why its column values come from the table. If you want to return column values that differ from what the table holds, drop @table from the return type and name the columns with @field(name:). The Java signature stays exactly the same, the backing jOOQ record is read directly, and no re-select happens:
type FilmSummary { # no @table: read straight off the returned record
filmId: Int @field(name: "film_id")
title: String @field(name: "title")
}
type Query {
filmSummaries(ids: [Int!]!): [FilmSummary!]!
@service(service: {className: "...FilmSummaryService", method: "summarise"})
}
What dropping @table gives up is the catalog-driven surface on that type: @reference paths, @splitQuery children, @orderBy, pagination, and @node (which requires @table). So this is the right move for a projection type you shape yourself, and the wrong one for an entity you want the catalog features on.
Scalar return: graphql-java coerces
public static Integer filmCount(DSLContext dsl) {
return dsl.fetchCount(Tables.FILM);
}
When the return type is a non-table GraphQL scalar (or list of scalars), the Java return must be a value graphql-java can coerce. The framework wraps the result in a DataFetcherResult and lets graphql-java apply scalar coercion (e.g. Integer → Int!, BigDecimal → Float). No projection or batching is involved.
Class-backed payload: accessor-derived or lifter-driven
When the return type is a class-backed type (a custom payload whose backing class is reflected from this @service method’s return type), each row in the service’s result has children that the framework still resolves. Two paths drive the child-fetch dispatch:
-
Auto-derived from accessor. If the payload’s canonical record-component accessor returns the right shape (a
TableRecordsubclass,List<TableRecord>, orSet<TableRecord>whose element matches the child field’s@table), the classifier auto-derives an accessor-keyed batch key for the field, single or list to match the accessor’s cardinality. No extra directive is needed. -
Explicit
@sourceRow. When the catalog cannot derive the key (e.g. the payload carries a raw FK column instead of a typed record), declare a lifter that takes the parent and returns aRowNof the FK column values.
The recentlyCreatedFilms example exercises the lifter path:
# backed by ...CreateFilmPayload via the recentlyCreatedFilms @service producer's return type
type CreateFilmPayload {
languageId: Int!
language: [Language!]!
@sourceRow(
className: "...CreateFilmPayloadLifter",
method: "liftLanguageId"
)
}
type Query {
recentlyCreatedFilms: [CreateFilmPayload!]!
@service(service: {className: "...CreateFilmPayloadService", method: "recentlyCreatedFilms"})
}
The service hand-rolls three payloads with languageId`s `(1, 2, 1). The framework’s lifter-driven DataLoader dispatches one batched language lookup with the deduplicated key set {1, 2}, not three. The full decision tree for which path applies (and the four backing-class variants) lives in How-to: Result-type variants.
Map<Key, Value>: child @service with the mapped-batch shape
For @service on a child field (under @splitQuery, see next section), the return shape is a Map<Key, Value> indexed by the parent-key shape the classifier expects. The example schema’s FilmService.titleUppercase exercises the Record1<Integer> arm:
public static Map<Record1<Integer>, String> titleUppercase(Set<Record1<Integer>> filmIds, DSLContext dsl) {
List<Integer> ids = filmIds.stream().map(Record1::value1).toList();
Map<Integer, String> titlesById = dsl
.select(Film.FILM.FILM_ID, Film.FILM.TITLE)
.from(Film.FILM)
.where(Film.FILM.FILM_ID.in(ids))
.fetchMap(Film.FILM.FILM_ID, Film.FILM.TITLE);
Map<Record1<Integer>, String> result = new LinkedHashMap<>();
for (Record1<Integer> key : filmIds) {
result.put(key, titlesById.getOrDefault(key.value1(), "").toUpperCase());
}
return result;
}
The Set<Record1<Integer>> parameter is the framework’s batch: one entry per distinct parent the request touched. The returned Map’s keys must be the same instances (or value-equal substitutes) the framework supplied; the framework reads each parent’s value out of the map by lookup. Missing keys yield `null for the field on that parent.
The accepted source shapes are Set<Row<N>>, Set<Record<N>>, and Set<X extends TableRecord>. ServiceCatalog.classifySourcesType picks the matching batch shape from the parameter’s reflected element type. Row<N> works (use DSL.row(value) to construct keys back); Record<N> lets you call .value1()/.value2() to extract column values directly; the typed-record arm hands you a typed record carrying the key columns, which is often the most convenient key to write SQL against, and is also what names the key on a class-backed parent.
@service on a child field: the @splitQuery contract
@service on a non-root field is allowed only under @splitQuery. Without it, the field’s parent is fetched first as a single SQL pass and there is no batch dispatch shape; the classifier rejects the schema at build time.
Under @splitQuery, the runtime contract is:
-
The framework runs the parent query first. The parent’s selection set is collected into a
Set<KeyType>whereKeyTypeis one ofRow<N>/Record<N>/ a typedTableRecord. Every shape carries the same thing: the batch key’s columns. The shapes differ in how you read them, not in what they contain. -
The framework dispatches the child
@serviceonce per request with thatSet. -
The service returns a
Map<KeyType, ChildValue>. The framework reads each parent’s value from the map and resolves the child for that parent.
The example schema wires three sibling @service children on Film for the three accepted source shapes:
type Film @table(name: "film") {
titleUppercase: String @service(service: {className: "...FilmService", method: "titleUppercase"})
titleLowercase: String @service(service: {className: "...FilmService", method: "titleLowercase"})
titleTitlecase: String @service(service: {className: "...FilmService", method: "titleTitlecase"})
}
public static Map<Record1<Integer>, String> titleUppercase(Set<Record1<Integer>> filmIds, DSLContext dsl) { ... }
public static Map<Row1<Integer>, String> titleLowercase(Set<Row1<Integer>> filmIds, DSLContext dsl) { ... }
public static Map<FilmRecord, String> titleTitlecase(Set<FilmRecord> films, DSLContext dsl) { ... }
The classifier picks the variant by inspecting the `Set<E>’s element type:
-
Row<N>. Keys carry no value accessors; reconstruct keys withDSL.row(value)if you need to read or rebuild them. Use this when the SQL side composes against the row (DSL.row(FILM_ID).in(filmIds)). -
Record<N>. Keys expose.value1()/.value2()/etc. Use this when the body needs to extract column values from each key. -
X extends TableRecord. The framework supplies a typed record carrying the key columns. Use this when a typed record is the shape your SQL wants to work with; read the key off it with the generated accessor (film.getFilmId()). This is also the shape that names the key on a class-backed parent, where the element type decides which table the batch keys on; see Batching a child@serviceon a class-backed parent.
|
The keys carry the key columns, and nothing else. This is the contract in every shape, typed record included: a On a That is what
One statement for the whole batch, which is what the DataLoader dispatch exists to make possible. Reading The same rule runs the other way across the same boundary: a record your method returns for a |
The contract holds identically under both parent kinds, so the same typed-record @service child works under either without a change to its Java signature:
-
SQL-projected parent (the parent came from a framework SELECT, e.g. a
@tablequery or a split-query parent). The framework includes the parent’s key columns in that SELECT whenever the@servicefield is selected (the field’s own projection entry carries them, and the field’s fetcher only runs when it is selected), and builds the key record from them. -
Service-returned parent (the parent is itself a
@servicehanding back records for a@table-bound type). Those records are key carriers too, so the framework re-selects the parent rows by key before any child runs; the child then reads its key off a projected row, exactly as under an SQL-projected parent.
Batching a child @service on a class-backed parent
A child @service batches against a key its parent can produce, and the Sources parameter’s element type names that key. When the parent type carries @table, the element is the parent’s own record and the key is its primary key, so there is nothing to think about. When the parent is class-backed (a DTO a service returned, or a Java type an accessor chain reached), the element names whichever table the batch keys on, and the parent has to be able to produce it.
type Aktivitet { # class-backed: produced by a @service returning Aktivitet
navn: String
beskrivelse: String @service(
service: {className: "no.example.TekstService", method: "hentBeskrivelser"}
)
}
public static Map<AktivitetRecord, String> hentBeskrivelser(
Set<AktivitetRecord> keys, DSLContext ctx) { ... }
AktivitetRecord names the key: the framework resolves it to the aktivitet table and keys the batch on that table’s primary key. For the parent to supply it, one of three things must hold:
-
the parent’s backing class is an
AktivitetRecord, or -
the parent’s backing class exposes exactly one zero-arg accessor returning
AktivitetRecord, or -
the field declares
@sourceRow(className: …, method: …)naming a public static method that takes the parent and returns anAktivitetRecord.
Reach for the third when the parent class is not yours to edit, or when more than one accessor returns the declared record and the build asks you to break the tie:
beskrivelse: String @service(
service: {className: "no.example.TekstService", method: "hentBeskrivelser"}
) @sourceRow(className: "no.example.AktivitetKeyLifter", method: "key")
public final class AktivitetKeyLifter {
public static AktivitetRecord key(Aktivitet parent) {
var r = new AktivitetRecord();
r.setAktivitetId(parent.aktivitetId());
return r;
}
}
The declared producer wins over accessor inference wherever both would apply: writing it is how you say which record is the key, and inference does not run under it.
As everywhere else on the @service path, the records the framework hands you carry the key columns and nothing else, even when the accessor or producer it read them from returned a fully populated record. Fetch the rest through the injected DSLContext, in one query for the batch.
If none of the three holds, the build fails:
@service on 'Aktivitet.beskrivelse' declares a batch key of 'AktivitetRecord' (table 'aktivitet'), but the parent type's backing class 'no.example.Aktivitet' cannot produce one. Either be that record, or expose a zero-arg accessor returning 'AktivitetRecord' on that class, or declare @sourceRow(className: ..., method: ...) on the field naming a public static method that takes the parent and returns 'AktivitetRecord'. The third route is the one for a class that is not yours to edit, or a parent that carries only scalar key columns.
Two shapes are rejected by name rather than guessed at: more than one accessor returning the declared record (which of them produces the key is not determined by the element type alone, so declare the producer to break the tie), and an accessor or producer returning many of them (a child @service batches one key per parent, which is what its Map<Key, Value> return means).
One DataLoader per request, per field
The dispatch is DataLoader-batched: one invocation of the service method per request per field, regardless of how many parents the request touches. The cache key is path-scoped, request-scoped, and tenant-prefixed (see How-to: When to split queries for the full key shape and the consequences for aliased uses).
The performance shape: one Set<Key>-input call, one Map<Key, Value>-output result, regardless of fan-out. A request that touches 200 films sees one titleUppercase invocation with a 200-element Set, not 200 calls; missing keys in the returned Map yield null for that parent’s field.
Pitfalls
-
Argument binding is by name. GraphQL argument
idsbinds to Java parameteridsby default; rename viaargMapping:when the Java method’s parameter has a different name. Mismatches are caught at build time. -
Input types pass through whole. A
FilmCreateInput!argument becomes one Java parameter, not flattened into per-field parameters. Read the input’s components on the Java side. Prefer a typed input-class parameter (the binding the framework reflects) over the untypedMap<String, Object>projection. -
contextArguments:requires the request layer to set them. The framework readsenv.getGraphQlContext().get(name); if the servlet/filter doesn’t put the value there, the parameter isnullat call time. The classifier doesn’t validate the value is set; only that the Java parameter named to receive it exists. -
Non-root
@servicerequires@splitQuery. The classifier rejects a child@servicewithout it. The shape on the child isSet<Key>→Map<Key, Value>, not(Parent, Args) → Value. Single-statement nested resolution is not supported on the service path. -
Map keys must round-trip. The returned
Mapis read byframework-supplied Setelement. If your body constructs new keys (notRow<N>instances backed by the same value-equals semantics), the lookup misses and the field returnsnullfor every parent. Prefer to rebuild keys from the framework’sSetrather than constructing fresh ones. -
A
@table-bound return needs a keyed table, and unmatched keys drop. The framework re-selects the returned records by primary key, so a@tabletype over a key-less table is rejected at build time naming the table, and a key the table has no row for is absent from a list result (ornullfor a single one). A service that deletes rows and returns them has nothing left to re-select. -
A child
@servicebody fetches its own non-key data. No source shape, typed record included, carries parent columns beyond the key. Take theDSLContextand issue one batched query for what you need; the framework does not smuggle extra columns through the parent SELECT on your behalf. -
A
@tableparent with no primary key cannot host a batched child@service. On a@tableparent the element type must be the parent’s own record, so the batch key is that table’s primary key and a table without one leaves nothing to key on; the classifier rejects the coordinate at build time and names the table. -
Every child
@servicebatches. There is no per-parent service call: a child@servicewhose method declares noSourcesparameter is rejected at build time, whatever it returns. Take the keys and return them keyed (Map<Key, Value>) or positionally (List<Value>). -
Conditions on a
@servicefield are ignored. The reference is explicit: the generator does not splice generated SQL into a custom method’s body. If the service needs filtering, take the filter as an argument or read it from the input type. -
@serviceand@mutationare mutually exclusive. One field can have one or the other, never both. Use@mutationwhen the framework should generate the INSERT/UPDATE/UPSERT; use@servicewhen you need custom logic.
See also
-
@serviceis the directive surface; this recipe expands its three deferred topics. -
@mutationfor the framework-generated alternative on root mutation fields. -
@splitQueryis the per-parent batch wrapper non-root services require. -
How-to: Result-type variants covers the class-backed payload backing-class shape that the classifier reflects on to find children.
-
How-to: When to split queries covers the
DataLoadercache-key shape and the aliased-uses-don’t-share-batches consequence. -
How-to: The errors channel covers turning service-thrown exceptions into typed errors on a payload’s
errors:field.