Implementing Entity Resolvers with @key Directives
Entity resolution is the contract that lets an Apollo Federation v2 graph stitch a single object together from fields owned by independent subgraphs. The @key directive declares the identity the router uses to route and merge those fields, and a __resolveReference function hydrates the partial representation the router hands back. Get the pair right and cross-service queries resolve deterministically; get them subtly wrong and the router silently nulls branches or composition fails outright. This guide covers the exact SDL, resolver shapes, composition wiring, and performance tuning needed to implement robust entity resolvers in production. For where this fits in the wider graph, start from Subgraph Implementation & Entity Resolution.
Prerequisites
Concept Deep-Dive: What @key Actually Declares
In Federation v2, @key(fields: "...") designates the fields the router uses to uniquely identify an entity and to construct cross-subgraph _entities queries. The directive instructs the composition engine to treat those fields as the canonical identity for the type across the supergraph. At runtime, when a query crosses a subgraph boundary, the router extracts the key fields from the upstream response, packages them into an entity representation such as { __typename: "User", id: "..." }, and dispatches a batched fetch to the subgraph that contributes the remaining fields.
Two structural choices follow from this:
- Single-field keys (
@key(fields: "id")) suit flat, globally unique identifiers. They minimise serialisation overhead and keep query planning simple. - Composite keys (
@key(fields: "region id")) model partitioned or multi-tenant identity. The router treats the field set as one identity tuple and requires the exact shape — every field present, correct casing, matching scalar — during resolution.
Federation v2 no longer requires a manual @extends on referencing subgraphs; the composition engine propagates entity metadata. But every owning subgraph must implement __resolveReference — composition never synthesises one. A type referenced across subgraphs without a @key is treated as a value type and cannot be resolved across boundaries.
It helps to be precise about what “owning” means, because the term carries weight in Federation v2. The owning subgraph is the one that defines the entity’s key fields natively — without @external — and is responsible for hydrating the base record in its __resolveReference. Any number of other subgraphs may contribute fields to the same entity; they declare the type with a matching @key, mark the key fields @external, and add their own fields. There is no central registry of “who owns User” beyond what the SDL says: ownership is inferred entirely from which subgraph defines the key fields without @external. This is why a stray @external on a key field in what should be the owning subgraph is such a common and confusing bug — it silently transfers ownership to nobody, and composition rejects the result because no subgraph can produce the base entity.
The representation the router builds is deliberately minimal. It contains __typename plus exactly the fields named in the @key selection, and nothing else. Your __resolveReference therefore cannot assume any field beyond the key is present on the incoming reference — if you need more than the key to fetch the record, that “more” must be declared with @requires so the router fetches it first. Treating the reference as “just the key, always” prevents a whole category of resolver bugs where code reads a field that happens to be present in development but absent under a different query plan.
The diagram below shows the lifecycle of a single representation, from key extraction through resolver hydration to merge — the exact path every cross-subgraph entity field travels.
extend schema
@link(url: "https://specs.apollo.dev/federation/v2.9", import: ["@key"])
type User @key(fields: "id") {
id: ID!
email: String!
profile: Profile
}
The @key selection can also reach into nested fields — @key(fields: "org { id }") — when identity is borrowed from a related object. Keep keys shallow where you can; nested keys widen the representation the router must build and ship on every hop.
The choice of key field is the most consequential decision in an entity’s contract, and it is hard to reverse once the graph is live. A good key is stable for the lifetime of the record, globally unique within its type, and low-cardinality in the sense that it maps cleanly to one record. A primary id from the owning store is almost always the right answer. Fields that look convenient but make poor keys include email addresses and usernames — they change, breaking every cached representation that referenced the old value — and status or category fields, which are not unique at all and will merge unrelated records. Because the key doubles as the entity cache key in production, a value that mutates does not merely break a lookup; it strands cached entities under stale keys and serves them until eviction. Choosing a key, then, is choosing an identifier you are prepared to treat as immutable.
Directive & Resolver Spec Table
| Element | Syntax | Valid values | Resolved at |
|---|---|---|---|
| Single key | @key(fields: "id") |
One scalar/ID field present in the type | Composition + runtime |
| Composite key | @key(fields: "region id") |
Space-separated field list, all present | Composition + runtime |
| Nested key | @key(fields: "org { id }") |
Selection into a related object | Composition + runtime |
| Multiple keys | repeated @key(...) on one type |
Each must be independently resolvable | Composition + runtime |
| Borrowed key field | id: ID! @external |
Only in extending (non-owning) subgraphs | Composition |
| Reference resolver | __resolveReference(ref, ctx, info) |
Returns full object or throws | Runtime |
The fields argument is an executable selection set, not a comma list. Field names and casing must match the type definition exactly; the composition engine validates that every referenced field exists and that its scalar type is identical across every subgraph that declares the key.
Step-by-Step Implementation
Step 1 — Define the entity in SDL
The owning subgraph declares the type with @key and defines every key field natively. Do not mark key fields @external here — that tells composition no subgraph owns them.
extend schema
@link(url: "https://specs.apollo.dev/federation/v2.9", import: ["@key"])
type User @key(fields: "id") {
id: ID!
email: String!
displayName: String
}
Step 2 — Implement __resolveReference
The resolver receives a partial object containing only __typename and the @key fields. It must return a fully resolved object matching the subgraph’s type. Throw on a missing record — a silent null/undefined makes the router drop the entire entity branch with no error in the trace.
import { buildSubgraphSchema } from "@apollo/subgraph";
import { ApolloServer } from "@apollo/server";
import { startStandaloneServer } from "@apollo/server/standalone";
import { gql } from "graphql-tag";
import { getUserById } from "./db";
const typeDefs = gql`
extend schema
@link(url: "https://specs.apollo.dev/federation/v2.9", import: ["@key"])
type User @key(fields: "id") {
id: ID!
email: String!
displayName: String
}
`;
const resolvers = {
User: {
__resolveReference: async (reference: { id: string }) => {
// reference contains { __typename: "User", id: "..." }
const user = await getUserById(reference.id);
if (!user) {
// throw explicitly so the failure surfaces instead of nulling the branch
throw new Error(`User entity not found for id: ${reference.id}`);
}
return {
__typename: "User",
id: user.id,
email: user.email,
displayName: user.displayName,
};
},
},
};
const server = new ApolloServer({
schema: buildSubgraphSchema({ typeDefs, resolvers }),
});
const { url } = await startStandaloneServer(server, { listen: { port: 4001 } });
Step 3 — Handle composite and structured keys
The router passes composite keys as a flat object containing each named field. Normalise and validate them before querying a partitioned store.
extend schema
@link(url: "https://specs.apollo.dev/federation/v2.9", import: ["@key"])
type Order @key(fields: "region id") {
region: String!
id: ID!
status: OrderStatus!
items: [OrderItem!]!
}
const resolvers = {
Order: {
__resolveReference: async (reference: { region: string; id: string }) => {
const { region, id } = reference;
if (!region || !id) {
throw new Error("Invalid composite key payload");
}
return fetchOrderFromPartition(region, id);
},
},
};
When key fields are structured identifiers — UUIDs, encoded composite IDs, or domain-specific tokens — define them as custom scalars with explicit serialize, parseValue, and parseLiteral so the router and every subgraph agree on the wire format. The patterns for that live in Custom Scalars in Federated GraphQL Schemas.
Composite keys carry a cost worth weighing before you reach for them. Every additional field in the key widens the representation the router builds and ships on every hop, increases the surface that must match exactly across subgraphs, and complicates cache invalidation because the cache key is now a tuple. They earn their place when identity is genuinely partitioned — an Order that only makes sense within a region, a tenant-scoped record where the same logical id repeats across tenants. They are a mistake when used to paper over a single field that should have been globally unique in the first place. The rule of thumb: reach for a composite key when the data is physically partitioned by those fields, and prefer a single stable ID! everywhere else. If you find yourself adding fields to a key to make it unique, the better fix is usually a synthetic surrogate id on the owning record.
A type may also declare more than one @key, exposing several independent identity boundaries — for example @key(fields: "id") and @key(fields: "email") on a User. This is legitimate when different subgraphs naturally reference the entity by different identifiers, but it puts a burden on the resolver: a single __resolveReference may receive a reference keyed by id or by email, and must inspect which fields are present before choosing its lookup. Keep multi-key entities rare and document why each key exists, because every additional key is another shape the resolver and every downstream cache must handle.
Step 4 — Pair resolution with cross-service dependencies
When a contributed field depends on data owned elsewhere, declare the borrowed field @external and the computed field @requires. The router fetches the required field first, then runs your resolver. This is the bridge to Using @external and @requires for Field Resolution, which covers the dependency-modelling patterns in depth.
extend schema
@link(url: "https://specs.apollo.dev/federation/v2.9",
import: ["@key", "@external", "@requires"])
type User @key(fields: "id") {
id: ID!
email: String! @external
orderCount: Int @requires(fields: "email")
}
Composition Pipeline Integration
Entity contracts are enforced before runtime. Compose locally or in CI on every change, and gate merges on a registry-aware check.
# compose all subgraphs listed in the config into one supergraph
rover supergraph compose --config supergraph.yaml > supergraph.graphql
# validate a proposed subgraph against the published graph and recent traffic
rover subgraph check "$APOLLO_GRAPH_REF" \
--name accounts \
--schema ./accounts/schema.graphql
A CI step that fails the build on a broken entity contract:
# .github/workflows/schema.yml (excerpt)
jobs:
schema-check:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Install rover
run: curl -sSL https://rover.apollo.dev/nix/latest | sh
- name: Check subgraph
run: |
rover subgraph check "$APOLLO_GRAPH_REF" \
--name accounts --schema ./accounts/schema.graphql
env:
APOLLO_KEY: ${{ secrets.APOLLO_KEY }}
APOLLO_GRAPH_REF: ${{ vars.APOLLO_GRAPH_REF }}
rover subgraph check validates composition and runs operation checks against recent traffic, so a @key change that breaks a field clients actually query fails the gate — not just changes that conflict at the schema level. Publish with rover subgraph publish only after the check passes and the change merges.
Order the pipeline for fast feedback. Run a local rover supergraph compose as a pre-commit or pre-push step so a structurally broken schema never even reaches CI — it needs no credentials and completes in well under a second for most graphs. Reserve rover subgraph check for the pull-request gate, where its registry and traffic awareness earn the extra latency and the need for an APOLLO_KEY. Publishing then becomes a post-merge step that runs only on the default branch, against the same schema CI already validated. This three-stage shape — compose locally, check on the PR, publish on merge — means a breaking @key change is caught at the cheapest possible point: ideally on the developer’s machine, at worst on the PR, never in the running graph.
Be deliberate about key changes in particular. Because a @key is the routing identity, the merge key, and the entity cache key all at once, altering it is a breaking change to every subgraph that references the entity. Renaming a key field or narrowing its type should go through a staged migration — add the new key alongside the old, migrate referencing subgraphs, then remove the old — rather than a single edit. The registry check is what stops the single-edit version from shipping.
Performance & Scale Considerations
Entity resolution sits on the hot path of every cross-subgraph query, so resolver efficiency translates directly into supergraph latency. The router batches all keys for a given step into one _entities call, but without batching inside the subgraph, each key in that batch becomes its own database round trip — the classic N+1.
Batch with DataLoader
import DataLoader from "dataloader";
import { getOrdersByIds } from "./db";
// build per-request to prevent cross-request cache pollution
const createOrderLoader = () =>
new DataLoader(
async (keys: readonly { region: string; id: string }[]) => {
const ids = keys.map((k) => k.id);
const orders = await getOrdersByIds(ids);
// return results in the exact order of the input keys
return keys.map(
(key) =>
orders.find((o) => o.id === key.id && o.region === key.region) ?? null
);
},
{ cache: true }
);
const resolvers = {
Order: {
__resolveReference: (
reference: { region: string; id: string },
context: { loaders: { order: ReturnType<typeof createOrderLoader> } }
) => context.loaders.order.load(reference),
},
};
Three rules keep this fast and correct:
- Project only what is needed. Inspect the resolver
infoto build minimalSELECTstatements; the router discards unrequested fields, so fetching them only wastes bandwidth and serialisation time. - Scope loaders per request. Never share a DataLoader across requests — cross-request caching causes stale entity merges and race conditions. Build loaders in your context factory.
- Pick the right cache tier. Request-scoped batching collapses an N+1 into one or two DB hits with strong per-request consistency. A distributed cache (Redis/Memcached) lowers latency further but needs explicit invalidation keyed on the entity
@key.
| Strategy | Latency | Memory | Consistency |
|---|---|---|---|
Unbatched __resolveReference |
High (N+1) | Low | Strong, per request |
| DataLoader (request-scoped) | Low (1–2 DB hits) | Moderate | Strong, per request |
| Distributed cache (Redis) | Lowest | High (shared state) | Needs explicit invalidation |
The deeper treatment — fallback strategies for partial data, batch sizing, and cache-aware fetching — is in Optimizing Reference Resolvers for Performance.
It is worth understanding precisely where the N+1 comes from, because the fix depends on it. The router is already efficient: for a single execution-plan step that needs ten User entities, it sends one _entities query carrying ten representations. The amplification happens inside your subgraph, when GraphQL invokes __resolveReference once per representation in that batch. If each invocation issues its own SELECT ... WHERE id = ?, you have turned the router’s one network call into ten database calls. DataLoader closes the gap by deferring those ten load calls to the end of the event-loop tick and dispatching them as a single WHERE id IN (...). This is why the loader must be request-scoped and built in your context factory: a loader shared across requests would batch keys from unrelated operations together and, worse, serve one user’s cached entity to another. The per-request lifetime is not a performance tuning knob — it is a correctness boundary.
Batch sizing is the second lever. DataLoader’s maxBatchSize caps how many keys go into a single data-source call; left unbounded, a query that fans out to thousands of entities can build a query string or IN list large enough to hurt the database more than the round trips it saved. A bound in the low hundreds is a reasonable default, tuned to what your store handles comfortably. The third lever, projection, is independent of batching: even a perfectly batched query that selects every column wastes serialisation effort on fields the router will discard. Reading the resolver info to build a minimal column list keeps both the database and the wire lean. Together these three — batch, bound, project — turn entity resolution from the most common federation latency sink into a step that barely registers in a trace.
Failure Modes & Debugging
Entity type 'X' is missing a @key directive — a type is referenced across subgraphs but never declares a @key, so composition treats it as a value type. Add @key(fields: "...") to the owning type. The full diagnostic path is in Debugging Missing @key Fields in Apollo Federation v2.
Field 'X' in @key(fields: "X") is not defined in the entity type — the key references a field that is missing from the SDL block or misspelled. Declare the field with matching casing and type.
Field-type conflict on a key field — id: ID! in one subgraph and id: String! in another fails composition. Standardise identity fields on ID! everywhere.
Silent null branch at runtime — composition passes but a cross-subgraph field comes back null with no error. The cause is almost always a __resolveReference that returned undefined for a missing record. Throw an explicit error, and check the debug log shows the resolver receiving the key shape your SDL declares.
Reference missing a key field — the resolver receives { __typename: "User" } with no id. This means the upstream subgraph’s response did not include the key field in its selection, so the router could not build a complete representation. The fix is upstream, not in the resolver: ensure the owning subgraph actually returns the key field for the records it emits. The debug log makes this unambiguous — it prints the exact reference object handed to each __resolveReference, so you can see whether the key arrived intact before suspecting your own lookup.
When you do reach for the debug log, read it as a sequence rather than a dump. For any cross-subgraph field, the trace shows the owning subgraph returning the base record, the router extracting the key into a representation, the _entities dispatch to the contributing subgraph, and that subgraph’s resolver output. A break in that chain localises the bug instantly: a missing key at extraction time is an upstream-selection problem, a correct key followed by a null return is a resolver problem, and a correct key followed by a thrown error is exactly the loud failure you want. Most entity bugs that survive composition are resolved in a single read of this sequence.
Frequently Asked Questions
Can a single type define multiple @key directives?
Yes. A type can expose more than one identity boundary, for example @key(fields: "id") and @key(fields: "email"). Each key must be independently resolvable, so your __resolveReference needs to detect which key fields are present in the incoming reference and branch its lookup accordingly.
How does the router behave when a @key field is missing at runtime?
If a required key field is absent from the upstream response, the router cannot build a valid representation and drops the entity fetch, returning null for the dependent fields. Schema validation in CI plus resolver guards that throw on incomplete references prevent these silent drops from reaching production.
Should an entity resolver fetch the full record or only requested fields?
Fetch only what downstream needs. Over-fetching inflates latency and memory and increases serialisation cost; the router discards fields nobody requested. Use info-driven projection or DataLoader selection to keep the payload minimal.
What is the difference between @key and @external?
@key defines the identity the router uses to route and merge an entity across subgraphs. @external marks a field as owned by another subgraph and only referenced locally — for query planning or as the input to a @requires computation. A subgraph that owns a field never marks it @external.
Do composite-key fields need to arrive in a specific order?
No — the router passes them as a flat object keyed by field name, not positionally. Your resolver reads each field by name, so order is irrelevant, but every field named in the key must be present or resolution fails.