This recipe covers the federation key surface: how @key declares an entity, how graphitron wires entity resolution from @key and @node, what compound and multi-alternative keys do, and the resolvable: false flag for keys owned by another subgraph. It walks the path from "what @key does at all" through to declaring a reference-only entity that lives on another subgraph.
The framework-level federation surface (the @link opt-in, the runtime entity dispatcher, custom fetchers, the <schemaInput tag> flag) lives in How-to: Apollo Federation transport; this recipe assumes that wiring is in place.
What @key does
A federation @key on a type declares "this type is an entity, and a representation carrying these fields uniquely identifies one row". The federation gateway routes _entities lookups to your subgraph; graphitron wires the resolution per-type so each @key becomes a SELECT against the type’s @table.
extend schema
@link(url: "https://specs.apollo.dev/federation/v2.10", import: ["@key"])
type Customer @table(name: "customer") @key(fields: "customerId") {
customerId: Int! @field(name: "CUSTOMER_ID")
firstName: String! @field(name: "FIRST_NAME")
lastName: String! @field(name: "LAST_NAME")
}
The classifier reads @key(fields: "customerId"), walks customerId’s field-level `@field to find the column (customer.customer_id), and registers an entity-resolution alternative on the type. At runtime, an _entities representation { "__typename": "Customer", "customerId": 42 } becomes the SELECT:
SELECT customer.first_name, customer.last_name, customer.customer_id
FROM customer
WHERE customer.customer_id = 42
The dispatcher batches across all _entities representations for the same alternative on the same type into a single VALUES-derived-table query, so a request asking for fifty customers fires one statement, not fifty.
@key on a type without @table is rejected at classify time. The reference dispatcher SELECTs from a table; without one, there’s no source. The diagnostic spells out the missing @table.
Compound keys
fields: accepts a space-separated list. Every column on the list contributes to the key:
type FilmActor @table(name: "film_actor") @key(fields: "filmId actorId") {
filmId: Int! @field(name: "film_id")
actorId: Int! @field(name: "actor_id")
}
The dispatcher emits a multi-column VALUES join: each representation must carry both filmId and actorId, and the SELECT WHERE clause is (film_id, actor_id) IN ((?, ?), (?, ?), …). The arity of the VALUES-derived table matches the key’s column count.
The fields named in fields: must each resolve to a column on the type’s table. A field that is itself an @externalField or computed scalar can’t participate in a key; the diagnostic surfaces the field name and the reason.
Multiple @key alternatives
A type can declare multiple @key directives. Each is an alternative:
type Film @table(name: "film")
@key(fields: "filmId")
@key(fields: "filmId title") {
filmId: Int! @field(name: "FILM_ID")
title: String! @field(name: "TITLE")
}
The runtime dispatcher walks the alternatives in most-specific-first order: when a representation supplies both filmId and title, the compound key wins; when only filmId is supplied, the single-key alternative matches. Ties (two alternatives of the same size whose required fields are both subsets of the representation’s keys) are broken by SDL declaration order.
This matters for cross-subgraph contracts: a federation gateway may route a representation to your subgraph that supplies more fields than the narrowest alternative needs. The most-specific-wins rule means the dispatcher uses the richest information available, even if a less-specific alternative would also match. The query plan is the same SELECT shape; only the WHERE clause changes.
@key on @node types
Graphitron’s @node directive synthesises a @key(fields: "id", resolvable: true) per type. The opaque id: is decoded through the same NodeIdEncoder the Relay-side Query.node uses, and the resulting column values feed the same dispatcher as a @key(fields: "…") would. Point being: @node is enough on its own; you don’t need to add @key(fields: "id") for the synthesised path.
A @node type with an additional explicit @key gets both alternatives:
type Film implements Node @table(name: "film") @node
@key(fields: "filmId")
@key(fields: "filmId title") {
id: ID! @nodeId
filmId: Int! @field(name: "FILM_ID")
title: String! @field(name: "TITLE")
}
The synthesised NODE_ID alternative (decode id: through NodeIdEncoder) is recorded, plus the two consumer-declared DIRECT alternatives (filmId, then filmId title). At dispatch time, a representation with id: lands the NODE_ID arm; a representation with filmId and title lands the compound alternative; a representation with just filmId lands the single-column alternative. A representation that supplies all three fields lands the compound alternative (most-specific tie-break).
This layered shape is the right pattern when:
-
The Relay path (
Query.node(id:)) and the federation path (_entities) both need to work, and the Relay path must use opaque IDs (no leaking column values to clients). -
External subgraphs may reference the entity by either form (gateway-supplied opaque IDs from a previous Relay traversal, or column values from a fresh subgraph join).
The synthesised alternative is always added; explicit @key(fields: "id") on a @node type is folded into the same NODE_ID alternative, with the resolvable: value coming from the user’s directive (so @key(fields: "id", resolvable: false) on a @node carries resolvable: false through to the synthesised alternative).
resolvable: false: keys for reference
A federation entity can declare a key that this subgraph does not resolve. The federation spec calls this a non-resolvable key, and graphitron honours it: the dispatcher records the alternative but skips it at match time, so a representation matching this alternative yields null without firing a SELECT.
type Language @table(name: "language") @key(fields: "languageId", resolvable: false) {
languageId: Int! @field(name: "LANGUAGE_ID")
name: String! @field(name: "NAME")
}
The schema lists Language as a reference-only entity: the gateway sees the key, knows the type exists, but routes resolution to whichever subgraph declared a resolvable key for the same type. This is the federation-level "I know about this thing, but it’s not mine to fetch".
Two distinct uses:
-
This subgraph has the table but the entity is owned elsewhere. The local table is incidental (cached lookup data, denormalised columns); the canonical resolution lives on another subgraph.
-
This subgraph references the entity by FK but does not store it. The non-resolvable key lets a relationship typecheck without forcing the local subgraph to expose a SELECT.
resolvable: false is also legal on @node types via the synthesised path: @node @key(fields: "id", resolvable: false) declares the type as a reference-only entity even though @node would normally make it resolvable.
Wiring federation: @link, <schemaInput tag>, custom fetchers
Three wiring concerns surface once @key is in play. How-to: Apollo Federation transport covers them in full; the operational summary:
-
Opt in via
@link. Add anextend schema @link(url: "…", import: […])to one of your.graphqlsfiles. The plugin detects the link and wraps the assembled schema with the federation builder. Without@link, the schema is non-federation;@keyfails graphql-java parsing because the directive is not declared. -
Manual directive declarations. Alternative to
@link: declare each directive you use (directive @key(fields: String!, resolvable: Boolean = true) repeatable on OBJECT | INTERFACE). The build still wraps with federation when the catalog finds any federation directive applied. Mixing the two on the same directive (importing via@linkand redeclaring it locally) fails validation. -
<schemaInput tag>is federation-specific. Setting<schemaInput><tag>…</tag></schemaInput>in the plugin config emits@tag(name: "…")directives on schema elements. These are only meaningful to a federation gateway; the plugin treats this as an implicit Federation 2 opt-in, and synthesises a minimal@linkimport for@tagif you haven’t already declared one. -
Custom entity fetcher. The default dispatcher handles
@nodeand@keytypes graphitron classifies; for hand-rolled entity types or entities pulled from a non-graphitron source, supply your own fetcher via the two-argGraphitron.buildSchema(b, fed → fed.fetchEntities(myFetcher)). Custom fetchers must return entities the defaultresolveEntityTypecan recognise (jOOQRecord`s with a `__typenamecolumn).
Pitfalls
-
@keyrequires@table. The dispatcher SELECTs from a table; a@keyon a non-table-bound type is rejected at classify time. The exception is@nodetypes (which always carry an entity entry for theQuery.nodepath, regardless of@key). -
@keyis rejected on interface types in graphitron. The federation spec allows it, but graphitron’s dispatcher does not yet support@keyon interfaces; declare@keyon the implementing types instead. The diagnostic mentions this explicitly. -
Most-specific-wins applies across keys, not within. Within a single
@key, all required fields are mandatory; the alternative matches only when every required field is supplied. Across alternatives, the dispatcher picks the largest matching one. -
resolvable: falseis honoured but advisory. The dispatcher skips the alternative; if no other alternative matches, the result isnull. A non-resolvable key with no resolvable counterpart on any subgraph is a federation-graph composition error, not a graphitron build error; it surfaces at the gateway, not at build time. -
Compound keys must list column-backed fields only. A field that is
@externalFieldor otherwise non-column can’t participate in a@key; the diagnostic names the offending field. The fix: use a column-backed field, or restructure the key to avoid the non-column field.
See also
-
@nodeis the Relay/Query.nodedirective whose synthesised key feeds the federation_entitiespath. -
@nodeIddeclares the encodedid:field that NODE_ID-shape alternatives use. -
@fieldis the per-field column-mapping axis for entity key fields. -
How-to: Apollo Federation transport covers the framework-level wiring (
@link,<schemaInput tag>, custom fetchers) this recipe assumes is in place. -
How-to: Global object IDs covers the Relay-side
Query.nodedispatch andkeyColumnsmigrations that share infrastructure with@key.