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 Nodedeclares nodehood. -
@nodesupplies or overridestypeIdandkeyColumns. When the bound jOOQ class has already published them,@nodeis 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 |
|---|---|---|---|
|
|
type’s GraphQL name |
The discriminator embedded in the ID that tells |
|
|
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 Nodeis what opts the type in.@tableplus 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. -
@nodestill wins on any axis it names. Write@node(typeId: "46")to pin a wiretypeIddifferent 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
Nodeinterface (type X implements Node …). Without the interface,Query.node(id:)cannot return the type, and@nodealone is rejected. -
@nodeitself 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@nodewhen the generator has published nothing, or to override either value. -
The node’s own
id: ID!field does not need@nodeId. Theidfield satisfying theNodeinterface on a node type is a node ID by construction. OtherIDslots still require the directive. The one exception is a bound table that also has a column namedid: there the field is ambiguous and the build fails until you write@nodeIdor@field; see When the table has its ownidcolumn. -
keyColumnsmust form a primary key or another unique key on the bound table. The build fails if the columns don’t form a unique constraint. -
keyColumnsordering matters and is part of the ID’s wire format; reordering breaks already-issued IDs. -
typeIdcollisions across types are rejected at build time, whether thetypeIdis written, defaulted, or taken from catalog metadata. If two types share atypeId,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
-
@nodeIdis the field-level counterpart that encodes and decodes the ID. -
@tablesupplies the columns embedded in the ID. -
@lookupKeyon[ID!]decodes opaque node IDs into typed batch keys (composite-PK NodeId lookups). -
How-to: Global object IDs covers stable-ID strategies,
keyColumnsmigrations, andQuery.node/Query.nodesdispatch.