03 · Federation Concepts: Subgraphs, Entities & @key¶
One GraphQL schema for a whole company is great for clients and hard for the teams behind it:
one repository, one deploy, one team bottlenecking every change. Federation splits the
graph into subgraphs, each owned and deployed by a team, and composes them into one
supergraph that clients query through a router. This lesson builds two subgraphs with
@apollo/subgraph 2.15.1 and — before introducing any router — queries them directly, so the
two mechanisms everything rests on, _service and _entities, are visible rather than magic.
Lesson 04 adds the router.
The idea: entities¶
An entity is a type that more than one subgraph contributes fields to, identified by a key:
products subgraph reviews subgraph
type Product @key(fields: "upc") { type Product @key(fields: "upc") {
upc: ID! upc: ID!
name: String! reviews: [Review!]!
priceCents: Int! averageStars: Float
weightGrams: Int! }
}
Clients see one Product with all six fields. Each subgraph only knows its own fields plus the
key. When a query needs fields from both, the router fetches from one subgraph, extracts the
keys, and asks the other subgraph "give me these fields for the products with these keys".
Two subgraphs¶
import { buildSubgraphSchema } from "@apollo/subgraph";
import { parse } from "graphql";
const FED = `extend schema @link(url: "https://specs.apollo.dev/federation/v2.3", import: ["@key", "@shareable", "@external", "@requires"])`;
// ---------- products subgraph: owns Product
const products = [
{ upc: "p1", name: "Desk lamp", priceCents: 2499, weightGrams: 900 },
{ upc: "p2", name: "Bookshelf", priceCents: 8900, weightGrams: 15000 },
];
export const productsSchema = buildSubgraphSchema([{
typeDefs: parse(`${FED}
type Query { products: [Product!]! product(upc: ID!): Product }
type Product @key(fields: "upc") {
upc: ID!
name: String!
priceCents: Int!
weightGrams: Int!
}
`),
resolvers: {
Query: {
products: () => products,
product: (_, { upc }) => products.find((p) => p.upc === upc) ?? null,
},
Product: {
// Called when another subgraph's result needs Product fields owned here.
__resolveReference: (ref) => products.find((p) => p.upc === ref.upc) ?? null,
},
},
}]);
// ---------- reviews subgraph: owns Review, adds Product.reviews
const reviews = [
{ id: "r1", productUpc: "p1", stars: 5, body: "Bright and sturdy." },
{ id: "r2", productUpc: "p1", stars: 3, body: "Cable is short." },
{ id: "r3", productUpc: "p2", stars: 4, body: "Took an hour to build." },
];
export const reviewsSchema = buildSubgraphSchema([{
typeDefs: parse(`${FED}
type Query { latestReviews: [Review!]! }
type Review { id: ID! stars: Int! body: String! product: Product! }
type Product @key(fields: "upc") {
upc: ID!
reviews: [Review!]!
averageStars: Float
}
`),
resolvers: {
Query: { latestReviews: () => reviews.slice().reverse() },
Review: { product: (r) => ({ __typename: "Product", upc: r.productUpc }) }, // a reference
Product: {
__resolveReference: (ref) => ref, // nothing stored per product here; the key is enough
reviews: (p) => reviews.filter((r) => r.productUpc === p.upc),
averageStars: (p) => {
const rs = reviews.filter((r) => r.productUpc === p.upc);
return rs.length ? rs.reduce((s, r) => s + r.stars, 0) / rs.length : null;
},
},
},
}]);
Points to note:
extend schema @link(url: ".../federation/v2.3", import: [...])opts the SDL into Federation 2 and imports the directives used.Review.productreturns{ __typename: "Product", upc }— a reference. The reviews subgraph doesn't know a product's name or price, and doesn't pretend to; the router will fill those in from the products subgraph.Product.__resolveReferencein each subgraph is called with a representation ({ __typename, upc }) and returns the subgraph's own object for that key.
A version gotcha¶
Many examples call buildSubgraphSchema({ typeDefs, resolvers }) with a single object. With
2.15.1 that failed here:
TypeError: doc.definitions is not iterable (cannot read property undefined)
at concatAST (.../graphql/utilities/concatAST.js:32:17)
at buildSubgraphSchema (.../@apollo/subgraph/dist/buildSubgraphSchema.js:19:50)
This version treats a non-array argument as a bare SDL document; the { typeDefs, resolvers }
module form is only recognised inside an array, so the code above passes
[{ typeDefs, resolvers }]. The array form works across versions, so prefer it.
What buildSubgraphSchema adds¶
import { graphql, printSchema } from "graphql";
import { productsSchema, reviewsSchema } from "./subgraphs.js";
const run = async (label, schema, source, variableValues) =>
console.log(`--- ${label}\n${JSON.stringify(await graphql({ schema, source, variableValues }))}`);
// 1. What the subgraph adds to its own schema
console.log("root Query fields in reviews subgraph:", Object.keys(reviewsSchema.getQueryType().getFields()).join(", "));
console.log("_Entity union members:", reviewsSchema.getType("_Entity").getTypes().map((t) => t.name).join(", "));
// 2. _service { sdl }: the subgraph's SDL *with* federation directives
const sdl = (await graphql({ schema: reviewsSchema, source: "{ _service { sdl } }" })).data._service.sdl;
console.log("\n--- reviews _service.sdl (Product part)\n" + sdl.split("\n").filter((l, i, a) => {
const start = a.findIndex((x) => x.startsWith("type Product"));
return i >= start && i <= start + 4;
}).join("\n"));
// 3. _entities: how a router asks a subgraph about objects it doesn't own
const ENTITIES = `query($reps: [_Any!]!) { _entities(representations: $reps) { ... on Product { upc reviews { stars } averageStars } } }`;
await run("reviews._entities for two products", reviewsSchema, ENTITIES,
{ reps: [{ __typename: "Product", upc: "p2" }, { __typename: "Product", upc: "p1" }] });
await run("products._entities resolving references", productsSchema,
`query($reps: [_Any!]!) { _entities(representations: $reps) { ... on Product { upc name priceCents } } }`,
{ reps: [{ __typename: "Product", upc: "p1" }, { __typename: "Product", upc: "zzz" }] });
// 4. A subgraph alone can only answer its own part
await run("reviews subgraph alone", reviewsSchema, `{ latestReviews { stars product { upc } } }`);
$ node concepts03.mjs
root Query fields in reviews subgraph: latestReviews, _entities, _service
_Entity union members: Product
Two root fields appeared that we never wrote:
_service: _Service!returns{ sdl }, the subgraph's SDL including federation directives. Standard introspection can't express applied directives like@key(Level 3 · 03), so federation needs this side channel._entities(representations: [_Any!]!): [_Entity]!— the entity-fetching entry point._Entityis a union of every type with a@key.
_service¶
--- reviews _service.sdl (Product part)
type Product @key(fields: "upc") {
upc: ID!
reviews: [Review!]!
averageStars: Float
}
This is what composition tools read to build the supergraph.
_entities¶
--- reviews._entities for two products
{"data":{"_entities":[{"upc":"p2","reviews":[{"stars":4}],"averageStars":4},{"upc":"p1","reviews":[{"stars":5},{"stars":3}],"averageStars":4}]}}
--- products._entities resolving references
{"data":{"_entities":[{"upc":"p1","name":"Desk lamp","priceCents":2499},null]}}
This is exactly the request a router sends. It passes a list of representations; the subgraph
calls __resolveReference for each, in order, and the router's selection (... on Product {
… }) picks the fields. Results line up with the input positions — p2 then p1 — and an
unknown key (zzz) comes back as null in its slot. That ordered, batched shape is a
DataLoader in disguise: implement __resolveReference with a loader and one _entities call
becomes one database query.
A subgraph alone¶
--- reviews subgraph alone
{"data":{"latestReviews":[{"stars":4,"product":{"upc":"p2"}},{"stars":3,"product":{"upc":"p1"}},{"stars":5,"product":{"upc":"p1"}}]}}
Queried directly, the reviews subgraph can only return product keys. Asking it for
product { name } would be a validation error — name isn't in its schema. The router is what
turns references into full objects.
Federation vocabulary¶
| Term | Meaning |
|---|---|
| Subgraph | A GraphQL service with federation directives; owned by one team |
| Supergraph | The composed schema of all subgraphs, plus routing metadata |
| Composition | Checking subgraphs are compatible and producing the supergraph (build time) |
| Router / gateway | Receives client queries, plans them across subgraphs, merges results |
| Entity | A type with @key, resolvable by key from any subgraph that defines it |
| Reference | { __typename, ...keyFields } pointing at an entity another subgraph will resolve |
@shareable |
Allows a field to be resolved by more than one subgraph |
@external / @requires |
Lets a subgraph compute a field from fields another subgraph owns |
@override |
Migrates a field from one subgraph to another |
When federation is worth it¶
Federation solves an organisational problem — many teams, one graph — with real costs: an extra network hop per subgraph involved, query planning, composition in CI, and distributed debugging. For one team with one service, a single schema (perhaps modular in code) is simpler. Lesson 09 weighs this in more detail.
How It Actually Works¶
buildSubgraphSchema parses your SDL, processes the @link to learn which federation
definitions to add, and appends them: the _Any scalar, _Service type, _Entity union (of all
@key types) and the two root fields. It builds the schema with buildASTSchema, attaches your
resolvers, and stores each type's __resolveReference in type.extensions.apollo.subgraph. The
_entities resolver maps over representations: for each, it looks up the type by __typename,
calls that type's stored __resolveReference(representation, context, info) (or, if there is
none, returns the representation itself), and the result's type is resolved through the _Entity
union using the representation's __typename. _service.sdl is printed from the original
document, which is how applied directives survive.
Common mistakes¶
- Returning full objects you don't own instead of references — the subgraph then has to know fields it doesn't own, and they drift.
- Keys that aren't stable or unique across subgraphs (one uses a database id, another a SKU).
- Slow
__resolveReferencewithout batching — an N+1 across the network. - Exposing subgraphs directly to clients.
_entitieslets callers fetch any entity by key, bypassing whatever checks the router path applies. Subgraphs should only accept traffic from the router. - Splitting by technical layer (a "database subgraph") instead of by domain ownership.
Exercise¶
- Add an
inventorysubgraph that contributesProduct.inStock: Boolean!, and query its_entitiesdirectly. - Implement
Product.__resolveReferencein the products subgraph with a DataLoader and log how many batch calls one_entitiesrequest with 50 representations makes. - Add
shippingEstimate: Int @requires(fields: "weightGrams")to the inventory subgraph (withweightGrams: Int! @external). What does its_entitiesrepresentation need to contain? - Print the full
_service.sdlof both subgraphs and identify everythingbuildSubgraphSchemaadded.