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. A nullable (ID) reference can be cleared: an explicit null writes NULL to 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 default MATCH 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 reference ID!.

# 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.

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 @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.

  • At a @service slot, 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 @table implementation must be a node type. A single-table @discriminate interface 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 @service slot 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 @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, 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

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