skip to content

How do composite and nested @key selection sets work in a federated subgraph?

level: middleimportance: must knowfreq 50%

answer

  1. The argument parses as a selection set
  2. Whitespace, not commas, joins the parts
  3. Braces reach into a sub-object
  4. One directive versus two means different things
  5. No arguments, no abstract types in a key

basics

~20 s

The fields argument is a selection set, not a list. Space-separated names make one composite key from several fields; braces reach into a sub-object. All named fields together form one identity, and each must be resolvable in the declaring subgraph.

solid answer

~50 s

`@key(fields: "sortCode accountNumber")` declares a **single composite key**: neither field identifies an account alone, and any subgraph referencing the entity must supply both. Braces reach one level deeper — `@key(fields: "ledger { id } accountNumber")` says the identity is the referenced ledger's `id` together with the account number, so the identifying value travels as a small nested structure rather than a flat pair. Two constraints follow from what a key has to be. A key field **may not take arguments**, because an identity that depends on caller input is not an identity. And it may not resolve to an interface or union type, because composition has to know at schema time exactly which concrete fields make up the identity. Everything named must also be resolvable in the subgraph declaring the key. Two separate `@key` directives mean something completely different: alternative keys, either sufficient on its own.

code

graphql · 19 lines
graphql
# ONE composite key: sortCode and accountNumber together
type Account @key(fields: "sortCode accountNumber") {
  sortCode: String!
  accountNumber: String!
  displayName: String!
}

# TWO alternative keys: either identifies the account alone
type Customer @key(fields: "id") @key(fields: "nationalIdHash") {
  id: ID!
  nationalIdHash: String!
}

# NESTED key: the parent's id plus an ordinal
type StatementLine @key(fields: "statement { id } lineNumber") {
  statement: Statement!
  lineNumber: Int!
  amountMinor: Int!
}

go deeper

for a junior

Know that the fields argument is written like a GraphQL selection set: names separated by spaces, braces for nested objects, and no commas. Recognising a composite key when you see one is enough at this level.

for a middle

Be ready to explain the mechanics: one directive with two fields is a composite identity, two directives are alternative identities, and every field named must be resolvable here and free of arguments.

for a senior

Show judgement about key shape — what a wide or natural composite costs every referencing service, and why a mutable component in a key becomes a production incident rather than a schema opinion.

for a principal

Own the convention across teams: whether the graph standardises on surrogate identifiers, who arbitrates when two services want different natural keys, and how key shape constrains future service decomposition.

## The argument is a selection set, and that is the whole idea The value of `@key`'s `fields` argument looks like a string, but it is parsed as a GraphQL **selection set** over the type the directive is attached to. That single fact explains the entire syntax. Selection sets separate fields by whitespace, not commas. Selection sets nest with braces. Selection sets cannot express anything a GraphQL document could not express. So a key is written the way you would write the *query for the identity*, minus the outer braces. ```graphql type Account @key(fields: "sortCode accountNumber") { sortCode: String! accountNumber: String! displayName: String! } ``` ## Composite keys That is one key made of two fields, not two keys. In a bank statements graph, a sort code identifies a branch and an account number is only unique within one; neither is an identity by itself, and the pair is. Consequences follow immediately: * Every subgraph that wants to reference an `Account` must be able to produce **both** values. If the statements service only stores the account number, it cannot reference the entity by this key at all. * The identity that crosses the wire when the router moves from one subgraph to another carries both fields. A partial key is not a key. * Both fields must be resolvable in the subgraph that declares the directive. ## Nested keys Braces reach into an object-typed field: ```graphql type StatementLine @key(fields: "statement { id } lineNumber") { statement: Statement! lineNumber: Int! amountMinor: Int! } ``` This says a statement line is identified by *which statement it belongs to* plus its position within it. The identifying value is therefore a small nested structure — the statement's `id` inside an object, alongside a number — rather than a flat pair of scalars. Nesting is what lets you key a child by its parent's identity without inventing a synthetic composite string, and it is common wherever the natural identity of a row is "this parent, this ordinal". The same resolvability rule applies at every level: `statement` must be a field of `StatementLine` in this subgraph, and `id` must be a field of `Statement` here too. ## Two rules about what may appear in a key **A key field may not take arguments.** Think about what would follow if it could. `@key(fields: "balanceAt(on: $when)")` would make the identity depend on a value nobody agreed on — the router would have to invent an argument to build a reference, and two services could produce different identities for the same object. An identity has to be a deterministic, argument-free projection of the object, computable by anyone holding it. **A key field may not resolve to an abstract type.** If `owner` returned an interface, the concrete fields making up the identity would not be knowable when the schemas are composed; the shape of the identifying value would depend on runtime data. Composition needs a fixed, concrete shape, so it rejects the schema rather than shipping a graph whose keys change shape per request. Both rules come from the same principle. The key must be reducible to a small, flat-serialisable snapshot of concrete field values that any service can construct and any service can recognise. ## Composite keys versus alternative keys The most common misreading in an interview is treating these as the same thing: ```graphql # ONE composite key: both fields required together type Account @key(fields: "sortCode accountNumber") { ... } # TWO alternative keys: either is sufficient alone type Account @key(fields: "id") @key(fields: "iban") { ... } ``` The first says the identity is a pair. The second says there are two ways to name the same object, and the router picks whichever one it can satisfy from data it has already fetched — useful when different services naturally learn different identifiers. `@key` is repeatable precisely so the second form is possible. ## Practical consequences when choosing key shape A composite key is wider than a single field, and every referencing subgraph pays for that width: it must carry all the parts through its own storage and its own responses. If the payments service holds only an internal account id, a `sortCode accountNumber` key forces it to store or fetch both. That is often the real argument for minting a surrogate identifier and keying on it — not elegance, but the cost the key imposes on every service that references the entity. And wider keys hide a subtler risk: the more fields an identity has, the easier it is for one of them to be mutable. A key must be stable for the entity's lifetime, and a natural composite is more likely to contain a value the business will eventually renumber than a synthetic identifier is.

  • Why can a key field not take arguments?
    Because the identity would then depend on caller input. A reference to an entity has to be constructible by any service holding the object, and recognisable by the service that resolves it; if the value changed with an argument, two services could produce different identities for the same object and neither could be sure which one the other meant. Identities must be deterministic, argument-free projections.
  • Can a nested key reach into a field only another subgraph can resolve?
    No. Every field named in a key selection set, at every level, must be resolvable in the subgraph declaring the key — that is half of what the declaration promises. A key path into data this service does not hold would leave it unable to produce the identity for objects it returns, and composition rejects it.
  • How does key width affect the services that only reference the entity?
    Every referencing subgraph must be able to produce the whole key. A two-field composite means each of them stores or fetches both parts, and a nested key means they carry the nested structure. That cost is usually the strongest argument for a surrogate identifier: a narrow synthetic key is cheap for eleven services to hold, while a natural composite quietly pushes storage into all of them.

saying these in an interview costs you the question

  • Writes the fields argument as a comma-separated list
  • Confuses one composite key with two alternative keys
  • Thinks a referencing subgraph may send part of a composite key
  • Puts an argument-taking field inside a key
  • Keys on a field only another subgraph can resolve
  • Uses a dotted path instead of braces for nesting

context