Resolving Entities Across Apollo Federation Subgraphs with @key and resolveReference
Split a GraphQL API into Apollo Federation subgraphs and resolve a shared entity across them using the @key directive and __resolveReference resolvers, with verification steps and pitfalls.
14 Aug 2026, 17:04 UTC

You split a monolithic GraphQL API into subgraphs — say a Users service and a Posts service — and now a single query like user(id: "1") { name posts { title } } needs data from both. Apollo Federation solves this with two pieces: the @key directive, which tells the gateway how to identify an entity across subgraphs, and a resolveReference resolver, which lets each subgraph reconstruct that entity from its key. This guide walks through wiring both up in Node.js.
What you need before starting
- Node.js 14 or later.
- Two standalone GraphQL services (subgraphs), each exposing its own schema. Examples here use Apollo Server 4 with
@apollo/subgraph; the same pattern works on Apollo Server 2/3 withbuildFederatedSchema. @apollo/gatewayfor the composition layer, plus@apollo/server.- A field on the shared entity that is genuinely unique across every instance (a database primary key or UUID — not an email that can change or collide).
Step 1: Mark the entity with @key in each subgraph
In the Users subgraph, define the entity and its key:
const typeDefs = `#graphql
extend schema @link(url: "https://specs.apollo.dev/federation/v2.3", import: ["@key"])
type User @key(fields: "id") {
id: ID!
name: String!
email: String!
}
type Query {
user(id: ID!): User
}
`;In the Posts subgraph, extend the same entity and add the fields that subgraph owns:
const typeDefs = `#graphql
extend schema @link(url: "https://specs.apollo.dev/federation/v2.3", import: ["@key"])
type User @key(fields: "id") {
id: ID!
posts: [Post!]!
}
type Post {
id: ID!
title: String!
}
`;Both subgraphs must agree on the @key fields. If Users keys on id and Posts keys on email, composition fails or resolution becomes ambiguous.
Step 2: Implement resolveReference in each subgraph
When the gateway already has a User's key from one subgraph and needs fields owned by another, it calls that subgraph's resolveReference with the key. Implement it in both services:
// Users subgraph resolvers
const resolvers = {
User: {
__resolveReference: async (reference, { dataSources }) => {
return dataSources.usersDb.findById(reference.id);
},
},
Query: {
user: (_, { id }, { dataSources }) => dataSources.usersDb.findById(id),
},
};// Posts subgraph resolvers
const resolvers = {
User: {
__resolveReference: async (reference, { dataSources }) => {
// Only needs to return the key plus fields this subgraph owns.
return { id: reference.id };
},
posts: (user, _, { dataSources }) =>
dataSources.postsDb.findByAuthorId(user.id),
},
};Note the asymmetry: the Posts subgraph's __resolveReference can return just the key, because the actual posts data is fetched by the field resolver. The Users subgraph must return the full object it owns.
Step 3: Start the subgraphs and gateway
Run each subgraph with Apollo Server on distinct ports (e.g., 4001 and 4002), using buildSubgraphSchema from @apollo/subgraph:
import { buildSubgraphSchema } from "@apollo/subgraph";
const server = new ApolloServer({
schema: buildSubgraphSchema({ typeDefs, resolvers }),
});Then create the gateway, which introspects both subgraphs and composes a single schema:
import { ApolloGateway } from "@apollo/gateway";
const gateway = new ApolloGateway({
supergraphSdl: undefined, // using IntrospectAndCompose below
});
const gatewayServer = new ApolloServer({
gateway: new ApolloGateway({
supergraphSdl: new IntrospectAndCompose({
subgraphs: [
{ name: "users", url: "http://localhost:4001/graphql" },
{ name: "posts", url: "http://localhost:4002/graphql" },
],
}),
}),
});Run these as three separate processes. You need network access from the gateway to both subgraphs; no special permissions beyond that. The risk to watch: if composition fails (mismatched keys, missing directives), the gateway refuses to start and logs the composition errors — read those logs first before debugging resolvers.
Step 4: Verify cross-subgraph resolution
Send a query that spans both subgraphs:
query {
user(id: "1") {
id
name
email
posts { title }
}
}Expected behavior: id, name, and email come from the Users subgraph; posts comes from Posts. To confirm the federation path actually executed, add a console.log inside each __resolveReference — you should see the Posts subgraph's reference resolver fire once per user in the result set. You can also introspect the gateway schema and confirm User appears with fields from both services.
Limitations and pitfalls
- Key uniqueness is your responsibility. Duplicate keys across instances cause the gateway to merge the wrong entities silently or throw resolution errors.
- N+1 risk. The gateway calls
resolveReferenceper entity. Batch fetches with a DataLoader inside the resolver if lists of users are common. - Version mixing. Federation 1 and Federation 2 directives are not fully interchangeable; keep gateway and subgraph packages on compatible major versions.
- Circular references between subgraphs can stall composition — keep entity ownership one-directional where possible.
A practical end-to-end check: add a new nullable field to User in the Posts subgraph, restart that subgraph, wait for the gateway's poll interval, and query the new field. If it resolves, your key, reference resolver, and composition are all healthy.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.