Supplies the two identity parameters of a Relay Global Object Identification node: the typeId embedded in the ID, and the keyColumns it encodes.

Two sentences carry the whole model:

  • implements Node declares nodehood.

  • @node supplies or overrides typeId and keyColumns. When the bound jOOQ class has already published them, @node is optional.

A node type gets a synthesised opaque ID encoding (typeId, keyColumns); @nodeId fields encode and decode that ID, and the node’s own id: field needs no directive. The Query.node(id:) and Query.nodes(ids:) entry points dispatch by typeId prefix to the matching node type’s table at runtime.

@node only takes effect on types that also carry @table; the global ID embeds primary-key (or unique-key) columns from the bound table.

SDL signature

directive @node(
    typeId: String,
    keyColumns: [String!]
) on OBJECT

Parameters

Name Type Default Description

typeId

String

type’s GraphQL name

The discriminator embedded in the ID that tells Query.node which type (and table) to dispatch to. Defaults to the GraphQL type name. Set explicitly when you want shorter IDs, or stable IDs that survive a GraphQL type rename.

keyColumns

[String!]

primary-key columns

Ordered column list embedded in the ID. Must correspond to a primary key or another unique key on the bound table. Defaults to the primary key in declaration order. Set explicitly to decouple the ID from primary-key changes.

Canonical example

The example schema’s Customer, Address, and Film types all opt into Relay Node:

interface Node { id: ID! }

type Customer implements Node @table(name: "customer") @node {
    id:         ID!     @nodeId
    customerId: Int!    @field(name: "CUSTOMER_ID")
    firstName:  String! @field(name: "FIRST_NAME")
    # ...
}

type Address implements Node @table(name: "address") @node {
    id:        ID! @nodeId
    # ...
}

type Film implements Node @table(name: "film") @node {
    id:    ID! @nodeId
    # ...
}

@node here uses every default: typeId falls back to Customer, Address, Film; keyColumns falls back to each table’s primary key. The corresponding entry points:

type Query {
    node(id: ID!): Node
    nodes(ids: [ID!]!): [Node]
}

Query.node(id:) decodes the opaque ID, reads the embedded typeId, and dispatches to the matching @node type’s table. Query.nodes(ids:) is the batched form: the rewrite groups IDs by typeId, fans out one batched SELECT per type, and scatters rows back to the request’s original positions.

Composite-PK nodes follow the same shape:

type FilmActor implements Node @table(name: "film_actor") @node {
    id:      ID! @nodeId
    # ...
}

film_actor has a composite (actor_id, film_id) primary key; the synthesised ID embeds both columns in declaration order. @lookupKey on [ID!]! keyed by the same node type produces the corresponding VALUES join over both PK columns.

Set typeId explicitly when the GraphQL type name might change but the ID must remain stable:

type Person implements Node @table(name: "customer") @node(typeId: "C") {
    id: ID! @nodeId
}

The typeId: "C" keeps existing IDs valid even if Person is later renamed to User or Customer.

Several node types over one table

A table may back more than one @node type, each publishing its own typeId. The shape comes up when a federation entity owned by another subgraph has to issue the same global ID as a type you already expose, and when a renamed type lives alongside its predecessor through a deprecation window:

type LegacyCustomer implements Node @table(name: "customer") @node { id: ID! @nodeId }
type Customer implements Node @table(name: "customer") @node(typeId: "46") @key(fields: "id") {
    id: ID! @nodeId
}

Only the typeId values have to differ; the two types may embed the same keyColumns. Once a second @node covers the table, @nodeId can no longer infer which of them a leaf means from the table alone, so every leaf that is not the node’s own id: field names its type with @nodeId(typeName:). A named leaf reads typeId and keyColumns off the type it names, so the two node types stay independent regardless of how many share the table.

Inferring the identity parameters

When the bound table’s generated jOOQ class publishes node metadata, @node has nothing left to say and can be omitted:

type Studieprogram implements Node @table(name: "studieprogram") {
    id: ID!
    navn: String
}

typeId and keyColumns come from the catalog. The type is a node, Query.node(id:) can return it, and id publishes the encoded global ID, with no directive written anywhere. Sikt’s KjerneJooqGenerator publishes this metadata for every table that carries a platform node identity.

Two boundaries are worth knowing:

  • implements Node is what opts the type in. @table plus metadata and no interface stays an ordinary table type, which is what lets a nesting projection sit over a node-bearing table without becoming a second node.

  • @node still wins on any axis it names. Write @node(typeId: "46") to pin a wire typeId different from the published one; the key columns still come from the catalog.

When the table has its own id column

A table can publish node metadata and have a column literally named id. Graphitron will not choose for you: id could mean the encoded global ID or the column’s own value, both are legitimate, and the two are different values on the wire. The build fails until you say which:

type Doc implements Node @table(name: "doc") { id: ID! @nodeId }             # the global ID
type Doc implements Node @table(name: "doc") { id: ID! @field(name: "id") }  # the raw column

This applies wherever a node’s own id names a column that exists, whether the identity parameters were inferred or written in @node. @field is the escape hatch for exposing the column itself; note that a type whose Node.id is a raw column cannot round-trip through Query.node(id:).

The error names both remedies, so you do not need this page to resolve it:

[author-error] field 'Doc.id': table 'doc' has a column named 'id' and also publishes node
metadata, so 'id' is ambiguous. Add `@nodeId` to publish the global ID, or `@field(name: "id")`
to expose the raw column.

Constraints

  • The decorated type must also carry @table; the bound table supplies the columns to embed.

  • The type must implement the Node interface (type X implements Node …​). Without the interface, Query.node(id:) cannot return the type, and @node alone is rejected.

  • @node itself is optional when the bound jOOQ class publishes node metadata. implements Node @table(name: "x") is then a complete node declaration and takes both values from the catalog; see Inferring the identity parameters. Write @node when the generator has published nothing, or to override either value.

  • The node’s own id: ID! field does not need @nodeId. The id field satisfying the Node interface on a node type is a node ID by construction. Other ID slots still require the directive. The one exception is a bound table that also has a column named id: there the field is ambiguous and the build fails until you write @nodeId or @field; see When the table has its own id column.

  • keyColumns must form a primary key or another unique key on the bound table. The build fails if the columns don’t form a unique constraint.

  • keyColumns ordering matters and is part of the ID’s wire format; reordering breaks already-issued IDs.

  • typeId collisions across types are rejected at build time, whether the typeId is written, defaulted, or taken from catalog metadata. If two types share a typeId, Query.node(id:) cannot dispatch unambiguously.

Editor support

The graphitron LSP completes keyColumns: element values against the columns of the type’s @table-backed jOOQ class (the same column set @field(name:) completes against), flags typos per-element, and hovers each element to surface its GraphQL type. Cursor inside the empty list literal offers every column; once you start typing, the editor narrows to the matching prefix.

See also

  • @nodeId is the field-level counterpart that encodes and decodes the ID.

  • @table supplies the columns embedded in the ID.

  • @lookupKey on [ID!] decodes opaque node IDs into typed batch keys (composite-PK NodeId lookups).

  • How-to: Global object IDs covers stable-ID strategies, keyColumns migrations, and Query.node/Query.nodes dispatch.