The Reference Problem
The Reference Problem
The reviews team wants product names on every review card. They do not own the product table, they cannot query the catalog database directly, and duplicating name into their service guarantees the two copies drift apart within a sprint. Federation's answer is an entity: a type that several subgraphs contribute fields to, identified by a key all of them declare.
# subgraph: catalog
type Product @key(fields: "id") {
id: ID!
name: String!
priceCents: Int!
}
type Query { product(id: ID!): Product }
# subgraph: reviews
type Product @key(fields: "id") {
id: ID! # the key must exist here too — it is the lookup handle
reviews: [Review!]!
}
type Review { id: ID! rating: Int! body: String! }
Read @key(fields: "id") as a contract: identity of a Product is its id, and every subgraph that contributes fields agrees on that. The gateway uses the contract to move between subgraphs. When the plan needs reviews for a product the catalog subgraph just returned, it hands reviews an entity representation — a stripped object carrying only the type name and the key fields:
{ "__typename": "Product", "id": "p1" }
Everything else in that payload is noise the gateway already knows or will fetch later. Two rules follow. First, every subgraph contributing fields to the entity must declare the same key definition, or the gateway has no agreed identity to translate on. Second, in Federation 2 you no longer write extend type Product; a plain type Product @key(...) in each subgraph is the normal form, and key fields are implicitly shareable so you do not mark them @external.
Entity Resolvers and the _entities Round Trip
Entity Resolvers and the _entities Round Trip
The gateway does not invent a new protocol for hops. It sends the receiving subgraph a generated operation against the spec's _entities field:
query ($representations: [_Any!]!) {
_entities(representations: $representations) {
... on Product { id reviews { rating } }
}
}
Your subgraph answers by implementing a reference resolver for each entity type — __resolveReference in Apollo Server and most federation libraries. The minimal version returns an object with just the key:
const resolvers = {
Product: {
__resolveReference(ref) {
// The gateway only asked for fields this subgraph owns; the key is enough
// to attach them, so no database read is required for pure references.
return { id: ref.id };
},
},
};
When the entity needs real data, one representation means one lookup and fifty representations mean fifty lookups unless you batch them:
const productLoader = new DataLoader(async (ids) => {
const rows = await db.product.findMany({ where: { id: { in: ids } } });
const byId = new Map(rows.map((r) => [r.id, r]));
// DataLoader demands one slot per requested key, in the same order.
// Unknown ids become null, which the gateway turns into a null field.
return ids.map((id) => byId.get(id) ?? null);
});
Product: { __resolveReference: (ref) => productLoader.load(ref.id) },
Two details decide whether this works in production. Order matters: the gateway zips representations to results positionally, so a resolver that returns a filtered list silently attaches one product's reviews to another. Batching is per round trip: the gateway coalesces every representation for the same subgraph and type into a single _entities call, and DataLoader then coalesces the individual __resolveReference calls inside that tick into one SQL IN query. A 40-item product list therefore costs one database round trip, not forty — this is the same anti-N-plus-one machinery you would build by hand, wired into the plan.
Composite Keys, Key Drift, and @requires/@provides
Composite Keys, Key Drift, and @requires/@provides
Not every type has a globally unique column. Multi-tenant tables key on tenant plus natural id, event stores key on stream plus offset. Federation takes a field set, so the key can be a tuple:
type Shipment @key(fields: "tenantId trackingNumber") {
tenantId: ID!
trackingNumber: String!
status: String!
}
The representation becomes { "__typename": "Shipment", "tenantId": "acme", "trackingNumber": "1Z999" }, and the resolver must look up on both columns — a resolver that reads only trackingNumber will happily return another tenant's shipment. Several @key directives on one type are legal and useful during migrations: declare the old key and the new key side by side, roll the new key out to every subgraph, then remove the old one.
Key drift is the slow failure. If catalog keys Product on id and inventory keys it on sku, the gateway can translate neither direction, and the mistake usually surfaces as an unsatisfiable composition error or, worse, as a null branch at runtime. The discipline: when you add a field to an entity, every subgraph that contributes fields to that entity declares the identical key field set in the same release.
Two directives shave hops off a plan, and both are easy to misuse:
| Directive | Meaning | Cost when wrong |
|---|---|---|
@requires(fields: "spec") |
gateway fetches this external field first and passes it in the representation | stale data if the owner changes shape |
@provides(fields: "displayName") |
when you arrive through me, these external fields come free | wrong values if the copy is not live |
@external alone |
declares a field without resolving it | composition fails if nothing consumes it in @key, @requires, or @provides |
A common design smell: marking a field @external "just in case". Externals that no directive consumes are dead weight that hides true ownership. Declare the field where it is resolved, reference it through @requires or @provides only when a measured plan needs it, and let composition reject the leftovers.
Practice Problems
The gateway sends the reviews subgraph three Product representations in one _entities call. Implement __resolveReference so the database is hit once, results line up with the representations, and unknown ids come back as null instead of throwing.
Example:
Input: representations = [ { __typename: "Product", id: "p1" }, { __typename: "Product", id: "p9" }, { __typename: "Product", id: "p2" } ] database rows: [{id:"p1", name:"Bottle"}, {id:"p2", name:"Mug"}]
Output: [ { id: "p1", name: "Bottle" }, null, { id: "p2", name: "Mug" } ] // exactly 1 SELECT ... WHERE id IN ('p1','p9','p2')
p9 does not exist, so its slot is null — the gateway nulls that branch rather than failing the operation. Position is preserved because the gateway zips representations to results index by index; returning only the two found rows would attach Mug to the p9 slot.
Optimal Solution — O(n) with a single batched query (O(n) round trips without batching) time, O(n) for the id index space
Create one DataLoader per request (or per subgraph) that groups ids into a single IN query, then have __resolveReference delegate to it so concurrent loads coalesce into one round trip.
import DataLoader from 'dataloader';
export function createProductLoaders(db) {
const byId = new DataLoader(async (ids) => {
const rows = await db.product.findMany({ where: { id: { in: ids } } });
const index = new Map(rows.map((r) => [r.id, r]));
// DataLoader requires one result per key, in the same order as the keys
return ids.map((id) => index.get(id) ?? null);
});
return {
Product: {
__resolveReference: (ref) => byId.load(ref.id),
},
};
} Edge Cases:
- Duplicate ids inside one representations array — the loader dedupes them into one row fetch
- Representation missing __typename or a key field: reject rather than guess the entity
- Composite keys require a compound DataLoader key so tenantId scopes every lookup
Quiz
1. What does the gateway send to a subgraph when it needs entity fields for objects another subgraph returned?
2. A list of 40 products each resolve reviews through an entity hop. How many database queries should the reviews subgraph issue if it batches correctly?
3. Two subgraphs declare different keys for the same entity: Product @key(fields: "id") and Product @key(fields: "sku"). What goes wrong?
4. What is the practical difference between @requires and @provides?
Flashcards
Question
Entity
Click to reveal answer
Answer
An object type with @key whose fields may be spread across subgraphs; identity travels as a representation.
Question
Representation
Click to reveal answer
Answer
{ __typename, ...keyFields } — the minimal payload the gateway sends to resolve an entity elsewhere.
Question
__resolveReference
Click to reveal answer
Answer
Per-entity reference resolver; must return results aligned to the representation order, null for unknown ids.
Question
Composite key
Click to reveal answer
Answer
@key(fields: "tenantId trackingNumber") — a field set used when no single column is globally unique.
Question
@requires vs @provides
Click to reveal answer
Answer
requires = extra fetch before the hop so a field can be computed; provides = take external fields for free on arrival.
Revision Notes
Key Takeaways
- 1. Every subgraph contributing fields to an entity declares the same key field set
- 2. Representations carry only __typename and key fields, and results must come back in input order
- 3. Batch entity lookups with a DataLoader: one _entities call becomes one database query
Interview Tips
- • Describe the hop mechanically: gateway builds representations → generated _entities operation → __resolveReference per object → merged back into the parent list
- • Be ready to say what breaks when a key changes and how you roll it out with two @key directives side by side
Cheat Sheet
Entity SDL
type Product @key(fields: "id") { id: ID! name: String! } # catalog
type Product @key(fields: "id") { id: ID! reviews: [Review!]! } # reviews
type Shipment @key(fields: "tenantId trackingNumber") { ... } # composite
Hop payload: { "__typename": "Product", "id": "p1" }
Generated operation
query ($representations: [_Any!]!) {
_entities(representations: $representations) { ... on Product { reviews { rating } } }
}
Resolver rules
- One _entities call per subgraph per plan step; batch lookups with DataLoader
- Return null for unknown ids; keep result order identical to representation order
- Key fields are implicitly shareable — do not mark them @external
- @external without @key/@requires/@provides = composition failure