@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:

  1. Subgraph A declares Organisation @key(fields: "id", resolvable: false) so the gateway knows the type exists but routes resolution to B.

  2. Subgraph A’s Customer.organisation field projects the representation: { id: <derived from customer.org_id> }.

  3. The constructed Organisation representation needs no @table (subgraph A doesn’t have the table) and no @field per column (the columns live on customer, not on a hypothetical organisation table). @experimental_constructType fits: the parent type carries @table, the child does not, and the directive’s selection: 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:

  1. Subgraph A’s Customer resolver fetches the customer row, including the org_id column.

  2. The Customer.organisation field uses the inline @experimental_constructType projection to produce { "__typename": "Organisation", "id": <org_id-value> } from the parent row.

  3. The federation gateway sees the representation, looks up its routing table for Organisation, sees subgraph B’s resolvable @key(fields: "id"), and dispatches the name resolution to B.

  4. The gateway stitches B’s name into 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.

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.

  • @experimental_constructType doesn’t compose with @field or @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 @key with @experimental_constructType today. 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_constructType is the directive that motivates this recipe; this recipe expands its non-resolvable-entity use case.

  • @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; alternative to @experimental_constructType when 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.node dispatch and keyColumns migrations that share infrastructure with @key.