@experimental_constructType points at this recipe for the non-resolvable-entity pattern that motivates the directive’s existence. To make sense of that pattern, you need the surrounding federation surface first: 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. This recipe walks the path from "what @key does at all" through to "how to project a stub of an entity that lives elsewhere".
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.
Non-resolvable entities and @experimental_constructType
Now the deferred pattern from @experimental_constructType: how do you expose a field on a local entity whose value is itself an entity owned by another subgraph?
Concretely: subgraph A has Customer (local, table-backed). Subgraph B owns Organisation. Subgraph A wants Customer.organisation: Organisation so a request like
{ customer(id: ...) { organisation { name } } }
routes through the gateway: the gateway resolves the name against subgraph B, but it needs subgraph A to produce the representation (the key fields B will resolve against). Subgraph A doesn’t have an organisation table; it has customer.org_id. The shape is:
-
Subgraph A declares
Organisation @key(fields: "id", resolvable: false)so the gateway knows the type exists but routes resolution to B. -
Subgraph A’s
Customer.organisationfield projects the representation:{ id: <derived from customer.org_id> }. -
The constructed
Organisationrepresentation needs no@table(subgraph A doesn’t have the table) and no@fieldper column (the columns live oncustomer, not on a hypothetical organisation table).@experimental_constructTypefits: the parent type carries@table, the child does not, and the directive’sselection:projects parent-table columns into the child’s fields inline.
The shape:
extend schema
@link(url: "https://specs.apollo.dev/federation/v2.10", import: ["@key"])
# Owned by this subgraph: full entity, full resolution.
type Customer @table(name: "customer") @key(fields: "customerId") {
customerId: Int! @field(name: "CUSTOMER_ID")
# Reference to an Organisation owned by another subgraph.
organisation: Organisation
@experimental_constructType(selection: "id: ORG_ID")
}
# Owned by another subgraph: declared here as reference-only.
# Note: NO @table, because we don't have an organisation table.
type Organisation @key(fields: "id", resolvable: false) {
id: ID!
name: String!
}
A request { customer(…) { organisation { name } } } flows like this:
-
Subgraph A’s
Customerresolver fetches the customer row, including theorg_idcolumn. -
The
Customer.organisationfield uses the inline@experimental_constructTypeprojection to produce{ "__typename": "Organisation", "id": <org_id-value> }from the parent row. -
The federation gateway sees the representation, looks up its routing table for
Organisation, sees subgraph B’s resolvable@key(fields: "id"), and dispatches thenameresolution to B. -
The gateway stitches B’s
nameinto A’s response shape.
The directive’s "constructed type must not carry `@table`" constraint is exactly the pivot: the local subgraph doesn’t have a table for the foreign entity. The "every column comes from the parent" rule pairs with the federation contract: the only thing this subgraph needs to produce is the key value, and the parent table is where that value lives.
When the foreign-entity key uses different field names from your local columns, the selection: mapping does the bridging:
type Customer @table(name: "customer") @key(fields: "customerId") {
customerId: Int! @field(name: "CUSTOMER_ID")
organisation: Organisation
@experimental_constructType(selection: """
id: ORG_ID,
slug: ORG_SLUG
""")
}
type Organisation @key(fields: "id slug", resolvable: false) {
id: ID!
slug: String!
name: String!
}
Both id and slug project from local columns into the representation; the foreign subgraph’s compound key resolves with what the gateway forwards.
The Sakila example schema doesn’t currently fold these two together: federated-schema.graphqls exercises @key (with Language @key(fields: "languageId", resolvable: false)), and directives.graphqls documents @experimental_constructType. When a fixture combining them lands, this recipe will be amended; until then, the pattern above is the recommended composition.
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. -
@experimental_constructTypedoesn’t compose with@fieldor@externalField. Its "constructed type must not carry@table, parent must carry `@table`" rules are a separate axis from the column-mapping axis. Pick one per field. -
Compound keys must list column-backed fields only. A field that is
@externalField,@experimental_constructType, 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. -
No fixtures combine non-resolvable
@keywith@experimental_constructTypetoday. The pattern above is the recommended shape, but the example-schema coverage is split across the two surfaces. When the combined fixture lands, this recipe will reflect it.
See also
-
@experimental_constructTypeis the directive that motivates this recipe; this recipe expands its non-resolvable-entity use case. -
@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; alternative to@experimental_constructTypewhen the constructed type does have its own@table. -
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.