Relay Global Object Identification gives every node a single opaque ID that identifies a row across the graph. Graphitron synthesises that ID from (typeId, primary-key columns) whenever a type carries @node and an id: ID! field decorated with @nodeId. Query.node(id:) and Query.nodes(ids:) dispatch on the embedded typeId to fetch the underlying row. This recipe covers the choices that affect long-term ID stability, the contract for decoding IDs back into typed keys at argument and input sites, and the dispatch shape of the two root entry points.
Wire format and the opacity contract
The opaque ID is base64-url-encoded "typeId:v1,v2,…" (no padding, UTF-8). Commas inside values are escaped as %2C. Generated NodeIdEncoder.encode<TypeName>(…) and NodeIdEncoder.decode<TypeName>(base64Id) helpers handle the round-trip.
Two things follow from the wire format:
-
The
typeId, thekeyColumns, and the order of those columns are part of the public ID contract. Changing any of them invalidates IDs that clients have already cached or persisted. -
Decoding errors stay opaque to clients on the dispatcher path.
Query.node(id:)andQuery.nodes(ids:)returnnullfor malformed, unknown-typeId, or no-such-row IDs (Relay spec). On the argument path, decode errors surface asGraphqlErrorExceptionbecause the request is asserting "these are valid IDs of this type"; see "Decode-side errors" below.
Stable-ID strategies
@node(typeId:) defaults to the GraphQL type name. Two reasons to set it explicitly.
Decouple from rename. If Customer might one day be renamed to Person, set typeId: "C" (or "customer", or any opaque token) at introduction time so the rename is a schema-only change:
type Person implements Node @table(name: "customer") @node(typeId: "C") {
id: ID! @nodeId
}
Already-issued IDs continue to decode after the rename because the wire format never carried the GraphQL name.
Shorter or namespaced IDs. The typeId is part of every ID’s wire bytes. Trimming Customer (8 bytes) to C (1 byte) compounds across batched lookups. Conversely, prefixing typeId per service (shop:Customer) is one way to keep IDs unique across federated subgraphs without coordinating on type names.
The build rejects two @node types that share a typeId (whether explicit or defaulted): Query.node(id:) cannot dispatch unambiguously, and the rewrite refuses to compile rather than silently route to the wrong table.
keyColumns migrations
The default @node(keyColumns:) is the bound table’s primary-key columns in declaration order. Override only when the PK is unstable (rare on production schemas) or when an alternate unique key is the better identity:
type FilmByCode implements Node @table(name: "film") @node(keyColumns: ["FILM_CODE"]) {
id: ID! @nodeId
filmCode: String! @field(name: "FILM_CODE")
}
FILM_CODE must be a unique key on film for the build to accept the directive; the build fails to resolve otherwise.
When you genuinely need to migrate keyColumns (the underlying PK is being changed, or you’re moving from PK-based IDs to a domain-key for stability), the safe sequence is:
-
Add the new candidate-key columns and a unique constraint, deploy, backfill.
-
In a single release swap
keyColumns:to the new column list. Every client cache or persisted ID issued before this release stops decoding; plan accordingly. -
Optionally retain the old type under a different
typeId(and a deprecation marker) for one release cycle so old IDs continue to dispatch while clients refresh.
The rewrite gives no automatic rolling-window for the wire format: a keyColumns change is a hard cut. If continuity matters, the right tool is a second @node type pinned to the old shape, not a graceful in-place migration.
Composite-PK nodes
Composite primary keys work the same way; the synthesised ID embeds every column in declaration order:
type FilmActor implements Node @table(name: "film_actor") @node {
id: ID! @nodeId
# ...
}
film_actor’s PK is `(actor_id, film_id); the wire format is base64-url("FilmActor:<actor_id>,<film_id>"). @lookupKey on [ID!]! keyed by FilmActor produces a VALUES(idx, actor_id, film_id) join over both PK columns; no per-element scatter is needed.
Reordering the PK columns at the catalog level changes the ID’s wire format. If your jOOQ catalog regenerates the columns in a different order (e.g. after a DDL rewrite), set keyColumns: explicitly to lock the order:
type FilmActor implements Node @table(name: "film_actor")
@node(keyColumns: ["actor_id", "film_id"]) {
id: ID! @nodeId
}
Cross-type IDs from FK paths
@nodeId without typeName: infers from the containing type when the field is on a @node type’s own id slot. When the slot encodes a different row’s identity, set typeName: explicitly and pair it with @reference:
type Customer implements Node @table(name: "customer") @node {
id: ID! @nodeId
addressNodeId: ID @nodeId(typeName: "Address")
@reference(path: [{key: "customer_address_id_fkey"}])
}
addressNodeId produces an Address global ID from the FK column on customer. Two things happen at projection time. First, the FK-mirror collapse: because customer.address_id == address.address_id, the rewrite reads the PK columns directly from the customer row without joining to address. Second, the encoder uses Address’s typeId and `keyColumns, not Customer’s. The result is a valid `Address ID that Query.node will dispatch to the address table.
The same pattern works for any FK that lands on a @node table. If two @node types bind to the same target table (rare but legal), set typeName: explicitly to disambiguate; inference rule (b) only fires when exactly one @node type matches.
Decode-side: arguments and inputs
@nodeId on an argument or input field reverses the direction: it decodes an opaque ID back into typed key columns. Two shapes are common.
Same-table NodeId argument. The argument decodes to the surrounding query’s PK columns and feeds an IN (…) lookup. @lookupKey is implicit on this shape:
type Query {
filmsByNodeIdArg(ids: [ID!]! @nodeId(typeName: "Film")): [Film!]!
}
The rewrite emits a VALUES(idx, film_id) JOIN film ON film.film_id = values.film_id shape, with each id decoded via NodeIdEncoder.decodeFilm(…) at request time. Adding an explicit @lookupKey is permitted but redundant.
Composite-PK NodeId argument. When the keyed type has a composite PK, the same shape widens to a multi-column VALUES:
type Query {
filmActorByNodeId(id: [ID!]! @lookupKey): [FilmActor!]!
}
Here @lookupKey is explicit because the keyed type is inferred from the return type, not from @nodeId(typeName:). The decoder yields (actor_id, film_id) per ID; the join is VALUES(idx, actor_id, film_id) JOIN film_actor ON … AND ….
Input-record field. The same decode contract applies inside an input type used in a mutation or filter:
input FilmSameTableNodeIdInput {
filmIds: [ID!] @nodeId(typeName: "Film")
}
The rewrite materialises filmIds as List<Integer> (or the typed PK shape) on the input record, with decode happening at the boundary.
Decode-side errors
Argument and input decoders use the ThrowOnMismatch contract: a malformed base64 ID, or an ID whose embedded typeId does not match the declared typeName:, fails the request as a GraphqlErrorException before any SQL runs. Clients see a structured GraphQL error pointing at the offending argument; the server never tries to use a half-decoded value as a query parameter.
This is intentionally different from the dispatcher path. Query.node(id:) and Query.nodes(ids:) use the NullOnMismatch contract: a malformed or wrong-typeId ID returns null (per Relay’s "if no such object exists, the field returns null"). The asymmetry is the opacity contract: clients should not be able to probe for valid IDs by mining server errors. Argument-typed @nodeId asserts the IDs are of the expected type; Query.node(id:) queries whether an ID resolves at all.
A @nodeId argument on a field returning an interface or union
What the argument asserts is a set of types when the field returns a multi-table interface or union and the @nodeId carries no typeName:. Each implementation infers its own node type from its own table, so one argument means "a Customer ID" against the customer table and "a Staff ID" against the staff table:
union AddressOccupant = Customer | Staff
type Query {
occupantById(id: ID! @nodeId): AddressOccupant
}
The generated fetcher dispatches: each branch of the query matches only the IDs it can decode, so exactly the branch owning the supplied ID returns a row, and a Relay client gets what the schema shape promises. The assertion still holds, over the candidate set rather than over a single type: an ID that no implementation can decode fails the request as a GraphitronClientException naming every candidate, before any SQL runs.
Invalid node id "MjAwMTE6LTE2" for this argument: decodes to type "20011", expected an id of one of: Customer, Staff
Two shapes stay outside dispatch. Writing @nodeId(typeName: "Address") pins one node type for every branch, which is the shape to reach for when the argument really does mean one type (an FK-target filter, say), and it keeps the single-type error message. And a bare @nodeId on a nested input field of such a field is rejected at build time rather than dispatched: name the type explicitly there.
Multitable filter inputs
A @nodeId filter on a query returning a multi-table interface decodes once and filters each participant branch. When every participant reaches the target through a unique single-hop FK, discovery is automatic. When a participant’s route is ambiguous or longer, state it per participant with @referenceFor:
input ApplicationFilter {
environmentId: ID @nodeId(typeName: "Environment")
@referenceFor(type: "FeideApplication", path: [{key: "feide_app_environment_fkey"}])
@referenceFor(type: "IdmApplication", path: [{key: "idm_app_env_fkey"}])
}
type Query {
applications(filter: ApplicationFilter): [Application!]!
}
The path runs from each participant’s own table toward the target, which is the direction the generated predicate reaches in. Participants you do not name keep automatic discovery.
When no generated route fits, @condition(override: true) on the leaf hands the whole predicate to your method: it receives each branch’s table and the raw ID, and the generated NodeIdEncoder decodes it (peekTypeId(id) reads the type discriminator without committing to a type; decodeEnvironment(id) returns the typed key record, null on malformed input or a type mismatch). override: false plus an unresolvable route still fails the build, because the implicit column predicate is then required to compose and there is nothing for it to bind.
Mixing the two contracts on one leaf, some branches generator-routed and some method-owned, is rejected at build time. The condition method’s table parameter is Table<?>-shaped, so a split would compile and show up only as a wrong WHERE at request time; the build names the split participants and the two remedies that work.
Query.node and Query.nodes dispatch
The two Relay entry points are emitted automatically when at least one @node type is classified for the schema:
type Query {
node(id: ID!): Node
nodes(ids: [ID!]!): [Node]
}
Query.node(id:) peeks the typeId via NodeIdEncoder.peekTypeId(id), dispatches through the entity-fetcher table for that type, and SELECT`s the requested fields plus a synthetic `typename column. The Node TypeResolver reads typename to route the row at scatter time. Null IDs, base64 errors, and unknown typeIds all return null.
Query.nodes(ids:) is the batched form. The dispatcher groups IDs by typeId, fans out one batched SELECT per type (DataLoader-backed so siblings sharing a typeId collapse into one round trip), and scatters rows back to each request’s original positions. Mixing Customer, Address, and Film IDs in one call still produces just three queries.
Both entry points only project the fields the client actually selects. The framework knows the type’s table from @node, so the projection respects @field(name:) aliases, @reference joins, and the rest of the directive surface uniformly.
Constraints and pitfalls
-
@noderequires the type to also carry@tableand to implement theNodeinterface. The build rejects bare@nodeand@nodeon non-Node-implementing types. -
@nodeitself is optional when the bound jOOQ class publishesNODE_TYPE_ID/NODE_KEY_COLUMNS:implements Node @table(name: "x")is a complete node declaration and takes both values from the catalog.@tableplus metadata without the interface is still an ordinary table type. -
id: ID!is required on every node type, but@nodeIdon it is not: the field satisfying theNodeinterface is a node ID by construction. When the bound table also has a column literally namedid, the field is ambiguous between the encoded global ID and that column, and the build fails until you say which: write@nodeIdfor the global ID, or@field(name: "id")to expose the raw column instead. -
keyColumns:must form a primary key or another unique key. Reordering the columns changes the ID’s wire format; treat the order as part of the public contract. -
typeId:collisions across types are rejected at build time. If you need two GraphQL types to share a backing table, give each a distincttypeId. -
@nodeIdis a binding directive, not a projection. The slot’s GraphQL type must beID!,ID,[ID!], or[ID!]!. -
On argument decode, an ID’s embedded
typeIdmust match the declaredtypeName:; mismatches fail the request. OnQuery.node/Query.nodes, the same mismatch returnsnull. -
On a same-table
@nodeIdargument,@lookupKeyis implicit; the rewrite emits theVALUES-join shape automatically. Explicit@lookupKeyis permitted but redundant.
See also
-
@nodeconfigures the owning type (typeId,keyColumns). -
@nodeIdmarks each encode/decode slot. -
@referencesupplies the FK path for cross-type IDs. -
@lookupKeyis the dispatch path for[ID!]-keyed argument lookups; same-table NodeId arguments imply it. -
How-to: Batch lookups covers the broader
@lookupKeypatterns including non-NodeId keys.