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

typeName

String

inferred

The @node type the ID belongs to. Case-sensitive. Required when the carrier site is ambiguous. Inferred when: (a) on a non-@reference object field, the containing type is itself a @node; (b) on a @reference field, a jOOQ-record input field, or an argument whose value a @service producer parameter receives by name, exactly one @node type binds to the same target table. At that argument the target table is the field’s own return table, or, where the return type binds none, the table @mutation(table:) names, which is what a delete surface returning a scalar binds against. Inference rule (b) is the only place the backing table decides anything: an explicit typeName: takes the ID’s typeId and key columns from the named type’s own @node, so several node types over one table never make a named leaf ambiguous.

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 @nodeId type’s @table is the param record’s own table, the decoded key loads into the record’s own key columns.

  • Cross-table FK reference. When the @nodeId type 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 @nodeId fields.

    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. Because that in-key half is identity, the reference can be re-pointed only within the same key value and can never be cleared, so it must be spelled ID!; the build rejects the nullable form where it straddles.

# 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 key is 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 @service argMapping entry 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.

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 @table parent, or a @reference path that lands every key position on the parent’s own row.

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 @record parent, a member of a @service payload, or a by-name property read.

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 EXISTS. See the multi-hop how-to.

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 declares implements Node over a table whose jOOQ class publishes node metadata. The build fails when typeName: resolves to a non-node type.

  • typeName: is required when neither inference rule fires: when the field’s type is not the containing type and no @reference provides 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.

  • @nodeId does not stand alone on a non-node type as its identity. Use it on the id: ID! field of a node type, or on slots that reference a node from elsewhere. On a node’s own id: field the directive is optional: that field is a node ID by construction, and typeName: 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: id on 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 for typeName: 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 @nodeId to select the node ID or @field(name: "…") to select the column. The message names both.

  • On [ID!] arguments where typeName matches the surrounding query’s return-type table, @lookupKey is implied; an explicit @lookupKey is 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 (no typeName:) 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. Write typeName: to pin one type instead. The same shape on a nested input field of such a field is a build error asking for typeName:. 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: false plus 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.

  • @nodeId is a binding directive, not a projection. The slot’s GraphQL type must be ID!, ID, [ID!], or [ID!]! (lists are valid for batch decode).

  • In an argMapping path, a @nodeId leaf must name typeName: 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; 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

  • @node configures the owning type (typeId, keyColumns).

  • @reference supplies the JOIN path when an ID is encoded from a different row than the parent.

  • @lookupKey is 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.