Names a GraphQL slot as a Relay Global Object Identification ID. The site axis decides direction: on a FIELD_DEFINITION the directive encodes the parent’s primary-key columns into the opaque ID; on INPUT_FIELD_DEFINITION and ARGUMENT_DEFINITION it decodes the ID back to typed key columns at the carrier. Pairs with @node, which configures the type that owns the ID.
SDL signature
directive @nodeId(
typeName: String
) on FIELD_DEFINITION | INPUT_FIELD_DEFINITION | ARGUMENT_DEFINITION
Parameters
| Name | Type | Default | Description |
|---|---|---|---|
|
|
inferred |
The |
Canonical example
Producing IDs on a @node type’s id slot (encode side):
type Customer implements Node @table(name: "customer") @node {
id: ID! @nodeId
customerId: Int! @field(name: "CUSTOMER_ID")
# ...
}
type Address implements Node @table(name: "address") @node {
id: ID! @nodeId
# ...
}
@nodeId here has no typeName: because case (a) applies: the containing type is a @node, so the inference is unambiguous. The selected id is base64-encoded (typeId, primary-key columns).
Cross-type reference (encode side, with explicit type):
type Customer implements Node @table(name: "customer") @node {
addressNodeId: ID @nodeId(typeName: "Address")
@reference(path: [{key: "customer_address_id_fkey"}])
}
addressNodeId produces an Address ID from the FK on customer.address_id. The @reference path points at the address row; @nodeId(typeName: "Address") encodes that row’s PK columns under the Address typeId. (FK-mirror collapse means no extra JOIN: customer.address_id == address.address_id.)
Decoding into typed keys at an argument or input field (decode side):
type Query {
# Same-table @nodeId on an argument: decodes opaque IDs into film primary keys
# and feeds a (film_id) IN (...) lookup. @lookupKey is implicit on same-table
# @nodeId arguments.
filmsByNodeIdArg(ids: [ID!]! @nodeId(typeName: "Film")): [Film!]!
# Composite-PK NodeId @lookupKey: each opaque ID decodes to (actor_id, film_id),
# joined via VALUES(idx, actor_id, film_id) on both PK columns.
filmActorByNodeId(id: [ID!]! @lookupKey): [FilmActor!]!
}
input FilmSameTableNodeIdInput {
filmIds: [ID!] @nodeId(typeName: "Film")
}
On the decode side the rewrite verifies the ID’s embedded typeId against the declared typeName:; mismatched IDs surface as GraphqlErrorException via the ThrowOnMismatch contract before any SQL runs.
Decoding into a jOOQ record at a @service param
When a @service method parameter is a generated jOOQ TableRecord (singular or List<…>), the framework builds the record at the fetcher boundary, and an @nodeId field on the backing input type decodes into columns on that record. The target columns are resolved by table identity:
-
Same-table identity. When the
@nodeIdtype’s@tableis the param record’s own table, the decoded key loads into the record’s own key columns. -
Cross-table FK reference. When the
@nodeIdtype is a different table (the common status / history / junction-row shape), the decoded key loads into the foreign-key child columns on the record, resolved through the FK constraint between the two tables. The FK is deduced when exactly one connects them; when several do, name it with@reference(path: [{key: "<fk-name>"}]). A record may carry several such FK-reference@nodeIdfields.On an
@mutation(typeName: UPDATE)input those child columns can overlap the row’s own key, which happens whenever the two tables share a qualifying column such as a tenant or institution number. The field then partitions per column: the columns outside the matched key are written, and the ones inside it are the row’s identity, never written and checked against whatever else supplies them before the statement runs. A nullable (ID) reference can be cleared: an explicitnullwritesNULLto the columns the reference owns, leaving the identity columns as the rest of the input set them. A half-null foreign key imposes no referential obligation under PostgreSQL’s defaultMATCH SIMPLE, so a cleared reference is an absent one rather than a dangling one. The build rejects one shape only: an optional reference that is the sole supplier of one of the matched key’s columns, since an omitted value would leave nothing to find the row by. Give that column another contributor, or spell the referenceID!.
# A jOOQ FilmActorRecord @service param. Each @nodeId references a *different* node
# type; the decoded ids load into the junction row's FK child columns (film_actor.film_id,
# film_actor.actor_id), resolved through the catalog FK constraints — not by name match.
input AssignFilmActorInput {
filmId: ID! @nodeId(typeName: "Film")
actorId: ID! @nodeId(typeName: "Actor")
}
A nullable (ID) field follows jOOQ’s changed-flag contract: an omitted key leaves its column unwritten (excluded from the service’s INSERT/UPDATE), an explicit null writes NULL, and a value decodes and loads. A non-null (ID!) field always decodes and throws on a malformed or wrong-type id. A self-reference (an @reference on a same-table @nodeId) is rejected as out of scope.
Projecting one key column into an argMapping parameter
Where the consumer is not a jOOQ record but a single value, a routine IN parameter or a @service
method parameter, the whole decoded key is the wrong shape. Open the node id with the key column you
want instead:
argMapping: "pOrganisasjonskode: input.organisasjonId.organisasjonskode"
organisasjonskode is a key column of the node type the @nodeId names, not a field of any SDL
type. typeName: is required at such a position: there is no containing table to infer the target
from. See Binding a parameter to a node id’s key column.
Decoding into a producer parameter named for the argument
An @nodeId argument on a @service field reaches the producer method through the
ordinary name match: a parameter called what the argument is called receives that argument’s value.
What it receives is the decoded key and never the opaque id, so the method signature is written in
the key column’s own terms:
type Query {
# film's node key is one column (film_id, an integer), so the parameter takes an Integer
films(key: ID! @nodeId(typeName: "Film")): [Film!]!
@service(service: {className: "com.example.FilmService", method: "byKey"})
# inventory's node key is two columns, so a single value has nowhere to put the second;
# the parameter takes the node type's own generated record instead
stock(key: ID! @nodeId(typeName: "Inventory")): [Film!]!
@service(service: {className: "com.example.FilmService", method: "inStock"})
}
public static Result<FilmRecord> byKey(Integer key) { ... } // one key column
public static Result<FilmRecord> inStock(InventoryRecord key) { ... } // the whole tuple
Two ways to get this wrong, and the build names both:
-
A parameter of the wrong type at a one-column key. Declaring
String keyis the shape that used to receive the base64 string. The build names the key column, the type jOOQ binds it as, and the type the parameter takes, and asks for the column’s own type. -
A single-valued parameter at a composite key. One parameter holds one value and the key has several, so the build names the count and the columns, and offers both remedies: the node type’s own generated record takes the whole tuple, or an
@serviceargMappingentry names the one key column you want.
A parameter whose type the classpath scan cannot read (a primitive, or a consumer compiled without
-parameters) is not refused: the decode is emitted on the key’s arity alone and javac is the
backstop, because a refusal naming a type nobody could read would be inventing one of its operands.
The bare spelling works here too, and inherits its target from the table the argument binds against:
the field’s own return table, or, where the return type binds none, the table
@mutation(table:) names.
type Query {
# no typeName:, and none needed: the field returns Film, Film's table is film,
# and Film is the one node type over it
films(key: ID! @nodeId): [Film!]!
@service(service: {className: "com.example.FilmService", method: "byKey"})
}
type Mutation {
# a delete surface returns a scalar, so the table comes from @mutation(table:)
deleteFilm(key: ID! @nodeId): String
@mutation(typeName: DELETE, table: "film")
@service(service: {className: "com.example.FilmService", method: "delete"})
}
Two shapes leave nothing to inherit, and both are refused rather than passed along: a field that
neither returns a table-bound type nor names a table with @mutation(table:), and a table more than
one node type declares itself over, which is two different key tuples. Write typeName: at either.
The build also speaks up where nothing decodes the id at all. A @nodeId on a filter argument or a
filter input field is only carried out where some lowering path installs the decode, and a
coordinate no path reaches used to compile clean and hand the query the base64 string, failing per
request as a cast that throws or an opaque string compared against an integer key column. Such a
coordinate now fails the build, naming the argument or input field, the node type, and the
consuming field it sits under. Where the remedy is to bind the id onto one key column, that is the
argMapping projection Projecting a key column out of a
node id describes; where it is to move the directive, the message says which slots the generator
already decodes.
An interface declaration is not one of those paths. SDL makes every implementation redeclare the
field and its arguments, and a directive written on the interface does not travel with them, so
write the @nodeId on each implementation’s own argument as well. Where none of them carries it,
the interface’s coordinate is where the build names the drop, that being the only place it is
written.
Decoding an id of any implementation into a record supertype
At a @service slot, typeName: may name a multitable interface or a union
instead of one node type. The slot then accepts the id of any @table implementation, and the Java
side is typed as something every implementation’s generated record is: org.jooq.Record,
TableRecord<?>, UpdatableRecord<?> when every implementation’s table has a primary key, or an
interface your jOOQ configuration makes the records implement. The generated fetcher reads the id’s
type prefix, decodes it into that implementation’s own record, and hands the service a record whose
runtime class says which implementation it was; an id of a type outside the interface fails the
request naming every implementation, before the service runs.
union AddressOccupant = Customer | Staff
input DeactivateOccupantInput {
occupantId: ID! @nodeId(typeName: "AddressOccupant")
}
public record DeactivateOccupantInput(UpdatableRecord<?> occupantId) {}
public static Boolean deactivate(DeactivateOccupantInput in) {
if (in.occupantId() instanceof CustomerRecord c) { ... }
if (in.occupantId() instanceof StaffRecord s) { ... }
}
The same works at a producer parameter named for the argument, and at a list slot ([ID!] into
List<UpdatableRecord<?>>). Every implementation must be a node type; the build names one that is
not. Typing the slot as one implementation’s record is refused: that is the single-type spelling, so
name the type instead of the interface.
Where the ID resolves
One invariant governs every coordinate: a consumer neither receives nor supplies the wire format.
An SDL slot carrying @nodeId is encoded on the way out and decoded on the way in, so a producer
method, a bean feeding one, and a SQL predicate alike see the key columns and never the opaque id.
The tables below say where each direction gets its value and where it puts it.
Encoding, on an output field:
| Where the value comes from | What the coordinate looks like | What graphitron emits |
|---|---|---|
Projected columns |
The key columns are selectable at the field’s own site: a |
The encode wraps the columns in the projection, so the id is built in the same read. |
A read value |
The field’s value arrives through a read rather than a projection: an accessor on a
|
The encode applies to whatever the read yielded. The node key must be one column, a single value having nothing to encode a tuple from. |
Decoding, on an argument or input field:
| Where the value goes | What the coordinate looks like | What graphitron emits |
|---|---|---|
The row’s own columns |
Every key position lands on a column of the queried row itself, which is the same-table case and the FK-mirror case. |
A predicate on the field’s own table, with no JOIN. |
The target table’s columns |
At least one key position does not land there, which is the junction and multi-hop case. |
A predicate on the node type’s own key columns inside a correlated |
A jOOQ record slot |
The value reaches Java at a slot typed as the generated record of the node type’s own table: a producer parameter, or a record-typed member of a consumer bean. |
The whole decoded tuple is loaded onto that record’s key columns. Any key arity. |
A single-valued slot |
The value reaches Java at any other slot, and the node key is one column. |
That column’s own value, decoded. |
Where a coordinate satisfies none of these the build says so rather than falling back: the decode either resolves or is refused by name, and no arm hands the wire format on.
Constraints
-
The named or inferred type must be a node type: either it carries
@node, or it declaresimplements Nodeover a table whose jOOQ class publishes node metadata. The build fails whentypeName:resolves to a non-node type. -
At a
@serviceslot,typeName:may instead name a multitable interface or union. The slot must then be typed as a supertype of every implementation’s generated record (org.jooq.Record,TableRecord<?>,UpdatableRecord<?>over primary-keyed tables), never one implementation’s record or a scalar, and every@tableimplementation must be a node type. A single-table@discriminateinterface that is not itself a node type is refused here: name one of its object types, or make it a node type. Naming a container anywhere the value does not reach a@serviceslot is refused too, the polymorphic decode being a slot rule. See the record-supertype section. -
typeName:is required when neither inference rule fires: when the field’s type is not the containing type and no@referenceprovides an unambiguous target table. At an argument feeding a producer parameter by name, that is a field that neither returns a table-bound type nor names a table with@mutation(table:), or a table several node types declare themselves over; see the producer-parameter section. -
@nodeIddoes not stand alone on a non-node type as its identity. Use it on theid: ID!field of a node type, or on slots that reference a node from elsewhere. On a node’s ownid:field the directive is optional: that field is a node ID by construction, andtypeName:is rejected there because the containing type has already answered which node it is. -
The directive is likewise optional at an input field or argument named for the target’s own
id:idon an input consumed against a node-backed table, or on an argument of a field returning a node type, reads as that node’s global ID. The name is what carries it, so a differently-named slot (ids,customerId) still needs the directive; graphitron does not guess at plurals or suffixes. Where the target table backs more than one node type the build fails and asks fortypeName:rather than picking one. -
A directive-less slot that collides with a real column of the same name is an error at every coordinate, output field, input field and argument alike: the encoded ID and the raw column are different values and the SDL has not said which is meant. Write
@nodeIdto select the node ID or@field(name: "…")to select the column. The message names both. -
On
[ID!]arguments wheretypeNamematches the surrounding query’s return-type table,@lookupKeyis implied; an explicit@lookupKeyis permitted but redundant. -
Decode-time ID/typeId mismatch fails the request with a GraphQL error; same with malformed base64.
-
On an argument of a field returning a multi-table interface or union, a bare
@nodeId(notypeName:) means "an ID of any implementation": each branch of the generated query matches only the IDs it can decode, and the request fails only when no implementation accepts the ID, naming them all. WritetypeName:to pin one type instead. The same shape on a nested input field of such a field is a build error asking fortypeName:. See the how-to section. -
On a filter input field or argument of a query returning a multi-table interface or union, the route from each participant’s table to the target is resolved once per participant. A participant with no unique single-hop FK to the target needs its own route: state it with
@referenceFor(type:, path:), which at this coordinate runs from the participant’s table toward the target, or hand the whole predicate to your method with@condition(override: true)on the leaf.override: falseplus an unresolvable route still fails, and a leaf where some participants route and others do not is rejected as a split contract. See Multitable filter inputs. -
@nodeIdis a binding directive, not a projection. The slot’s GraphQL type must beID!,ID,[ID!], or[ID!]!(lists are valid for batch decode). -
In an
argMappingpath, a@nodeIdleaf must nametypeName:explicitly; opening a list of node ids is reported as deferred, that shape naming the list of a key column across the decoded ids and parameter binding not emitting it yet. Whether you have to open the leaf with a key column depends on the node type’s key: with one key column you may leave it closed and that column is what the parameter receives, and with more than one the build asks you to name which, one binding carrying one value. Either way the parameter receives a key column’s own value and never the encoded id. See Binding a parameter to a node id’s key column. -
A single-valued Java slot carries two preconditions, and the build states whichever one fails. The node key must be one column, because one slot holds one value; and the slot’s declared type must be the type jOOQ binds that column as, compared without widening, because the value arriving is the column’s own. Both are the author’s to fix in their own signature. A slot typed as the node type’s generated record escapes both, taking the whole tuple at any arity. See the producer-parameter section.
Editor support
The graphitron LSP completes typeName: against the schema’s node types, whether they declare @node or take their identity from catalog metadata, and against the multitable interfaces and unions whose @table implementations are node types; typing into the empty quoted value offers every node-bearing type, and a value that does not resolve to one surfaces as an error inline. Hover on a resolved typeName: shows the type’s typeId and key-column list, pulling each column’s GraphQL type from @field / @table metadata.
See also
-
@nodeconfigures the owning type (typeId,keyColumns). -
@referencesupplies the JOIN path when an ID is encoded from a different row than the parent. -
@lookupKeyis the dispatch path for[ID!]-keyed argument lookups; same-table NodeId arguments imply it. -
How-to: Global object IDs covers cross-type IDs, decode-error handling, and same-table-nodeid filter inputs.