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.

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 an extend schema @link(url: "…​", import: […​]) to one of your .graphqls files. The plugin detects the link and wraps the assembled schema with the federation builder. Without @link, the schema is non-federation; @key fails 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 @link and 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 @link import for @tag if you haven’t already declared one.

  • Custom entity fetcher. The default dispatcher handles @node and @key types graphitron classifies; for hand-rolled entity types or entities pulled from a non-graphitron source, supply your own fetcher via the two-arg Graphitron.buildSchema(b, fed → fed.fetchEntities(myFetcher)). Custom fetchers must return entities the default resolveEntityType can recognise (jOOQ Record`s with a `__typename column).

Pitfalls

  • @key requires @table. The dispatcher SELECTs from a table; a @key on a non-table-bound type is rejected at classify time. The exception is @node types (which always carry an entity entry for the Query.node path, regardless of @key).

  • @key is rejected on interface types in graphitron. The federation spec allows it, but graphitron’s dispatcher does not yet support @key on interfaces; declare @key on 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: false is honoured but advisory. The dispatcher skips the alternative; if no other alternative matches, the result is null. 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 @externalField or 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

  • @node is the Relay/Query.node directive whose synthesised key feeds the federation _entities path.

  • @nodeId declares the encoded id: field that NODE_ID-shape alternatives use.

  • @field is 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.node dispatch and keyColumns migrations that share infrastructure with @key.