Designing Cross-Service Type References
When a GraphQL graph spans dozens of independently deployed services, the hardest design problem is no longer “what fields does this type have?” — it is “which service owns this type, and how do the others point at it without copying it?” A cross-service type reference is the contract that lets an Order defined in the orders service expose its customer field while the canonical Customer lives, and stays, in the accounts service. Get this wrong and you produce composition drift, duplicated source-of-truth, and runtime resolution failures that only surface under specific query shapes. This guide, part of GraphQL Federation Architecture & Design, walks through the entity model, the directives that wire references together, a runnable implementation, the composition pipeline that validates it, and the performance characteristics you need to plan for at scale.
Prerequisites
Before following the implementation steps, make sure you have the following in place. These assumptions hold for every code block on this page.
Concept Deep-Dive: Entities, References, and Ownership
In a federated graph an entity is a type that one subgraph owns and that any subgraph can refer to by its primary key. The @key directive marks that primary key. A reference is not a copy of the entity — it is a key-only stub ({ __typename: "Customer", id: "c_42" }) that one subgraph hands back so the router knows to resolve the rest from the owning subgraph. The router never reaches into a non-owning subgraph for full data; it carries the stub to the authoritative service and calls that service’s __resolveReference.
This distinction is the whole game. The orders subgraph can return a Customer object containing only id and the router will transparently fan that out to the accounts subgraph to fill in name, email, and the rest. The orders team writes no accounts logic and stores no accounts data — it only knows the shape of the key.
Where ownership lives
Ownership is declared, not inferred. The owning subgraph defines the type with its real fields and a @key. A referencing subgraph re-declares the type as a stub: the same @key, the key field marked @external, and nothing else unless it is contributing fields. The composition engine merges these declarations into a single supergraph type, recording which subgraph resolves which field. Two subgraphs claiming to own the same non-@shareable field is a composition error, by design — it forces a deliberate decision about source of truth. When you genuinely need overlap, the answer is the @shareable directive for overlapping types, not silent duplication.
Reference SDL on both sides
Owning subgraph (accounts):
extend schema
@link(url: "https://specs.apollo.dev/federation/v2.9",
import: ["@key"])
# accounts owns Customer in full
type Customer @key(fields: "id") {
id: ID!
name: String!
email: String!
}
Referencing subgraph (orders):
extend schema
@link(url: "https://specs.apollo.dev/federation/v2.9",
import: ["@key", "@external"])
# orders refers to Customer by key only — a stub, not a copy
type Customer @key(fields: "id", resolvable: false) {
id: ID! @external
}
type Order @key(fields: "id") {
id: ID!
total: Int!
customer: Customer! # cross-service reference
}
The resolvable: false flag on the orders side is the precise way to say “I reference this entity but I do not resolve it” — it suppresses the requirement that orders implement __resolveReference for Customer. When orders returns an Order, it sets customer to { id: "c_42" } and the router resolves the rest from accounts.
Directional design
Keep references unidirectional wherever the domain allows it. If Order.customer points at accounts and Customer.orders points back at orders, you have a bidirectional relationship that is easy to draw and easy to turn into an infinite resolution loop for certain query shapes. The full treatment of breaking those loops — contract subgraphs, ID-based deferral, router cycle behaviour — lives in Handling Circular Dependencies in GraphQL Federation. Decide direction at design time; it is far cheaper than untangling it after both teams ship.
Architecture: How a Reference Resolves
The diagram below traces a single query, order(id) { total customer { name } }, from client to two subgraphs and back. The router plans the work, fetches the Order from orders (which yields a Customer stub), then issues one _entities batch to accounts to hydrate the customer.
Step 2 is the crux: the orders subgraph returns a Customer it does not own, carrying only the key. Step 3 is where batching pays off — if the query had returned a list of ten orders, the router collects all ten customer stubs into a single _entities request rather than ten round-trips. Your job in the accounts subgraph is to make that one request resolve all ten keys efficiently, which is where DataLoader enters in the implementation below.
Directive & Config Spec Table
| Directive / key | Where | Syntax | Effect | Phase |
|---|---|---|---|---|
@key |
owning + referencing subgraph | @key(fields: "id") |
Declares the primary key the router uses to reference the entity | Composition |
@key(resolvable: false) |
referencing subgraph | @key(fields: "id", resolvable: false) |
Marks a key-only stub; no __resolveReference required here |
Composition |
@external |
referencing subgraph | field: T @external |
Field is owned elsewhere; this subgraph only references it | Composition |
@requires |
referencing subgraph | field: T @requires(fields: "weight") |
Router fetches the named external fields before resolving this field | Composition + runtime |
@provides |
resolving subgraph | field: T @provides(fields: "name") |
Lets this subgraph return nested external fields inline, sparing a hop | Composition + runtime |
@shareable |
multiple subgraphs | field: T @shareable |
Permits more than one subgraph to resolve the same field | Composition |
subgraph_traffic_shaping |
router.yaml |
per-subgraph timeout, deduplicate_query |
Per-service timeouts and request dedup at the router | Runtime |
Step-by-Step Implementation
Step 1 — Define the owning entity and its reference resolver
In the accounts subgraph, define Customer with a @key and implement __resolveReference so the router can hydrate stubs.
import { ApolloServer } from '@apollo/server';
import { buildSubgraphSchema } from '@apollo/subgraph';
import gql from 'graphql-tag';
const typeDefs = gql`
extend schema
@link(url: "https://specs.apollo.dev/federation/v2.9",
import: ["@key"])
type Customer @key(fields: "id") {
id: ID!
name: String!
email: String!
}
type Query {
customer(id: ID!): Customer
}
`;
const resolvers = {
Customer: {
// Called when the router hands accounts a key-only stub.
__resolveReference: (ref: { id: string }, ctx: Ctx) =>
ctx.customerLoader.load(ref.id), // batched — see Step 3
},
Query: {
customer: (_: unknown, { id }: { id: string }, ctx: Ctx) =>
ctx.customerLoader.load(id),
},
};
const server = new ApolloServer({
schema: buildSubgraphSchema({ typeDefs, resolvers }),
});
Step 2 — Declare the reference in the consuming subgraph
In orders, declare the Customer stub and the Order.customer field. The orders resolver returns only the key.
import { buildSubgraphSchema } from '@apollo/subgraph';
import gql from 'graphql-tag';
const typeDefs = gql`
extend schema
@link(url: "https://specs.apollo.dev/federation/v2.9",
import: ["@key", "@external"])
type Customer @key(fields: "id", resolvable: false) {
id: ID! @external
}
type Order @key(fields: "id") {
id: ID!
total: Int!
customer: Customer!
}
type Query { order(id: ID!): Order }
`;
const resolvers = {
Order: {
// Return ONLY the key — the router resolves the rest from accounts.
customer: (order: { customerId: string }) => ({ id: order.customerId }),
},
Query: {
order: (_: unknown, { id }: { id: string }, ctx: Ctx) =>
ctx.db.orders.findById(id),
},
};
export const schema = buildSubgraphSchema({ typeDefs, resolvers });
Step 3 — Batch reference resolution to kill N+1
__resolveReference runs once per entity in the _entities batch. Wire a per-request DataLoader so ten stubs collapse into one query. Detailed patterns live in Batching Entity Resolution with DataLoader.
import DataLoader from 'dataloader';
// Create per request to avoid cross-request cache pollution.
export const makeCustomerLoader = (db: Db) =>
new DataLoader<string, Customer | null>(async (ids) => {
const rows = await db.customers.findMany({ where: { id: { in: [...ids] } } });
const byId = new Map(rows.map((r) => [r.id, r]));
// Must return results in the SAME ORDER as the input keys.
return ids.map((id) => byId.get(id) ?? null);
});
Step 4 — Use @provides to skip a hop on hot paths
If orders frequently needs only the customer’s name, let accounts mark it @provides-able and have orders cache it, returning it inline to spare the _entities hop on the read path.
# orders subgraph: declare the externally-owned field it can pass through
type Customer @key(fields: "id") {
id: ID! @external
name: String! @external
}
type Order @key(fields: "id") {
id: ID!
customer: Customer! @provides(fields: "name")
}
When @provides(fields: "name") is set and orders returns customer.name inline, the router will not issue the accounts hop for name on that path — a meaningful win for high-fan-out list queries.
Composition Pipeline Integration
References are validated at composition time, so the first place a broken reference shows up should be CI, not production. Run rover supergraph compose to merge subgraphs and rover subgraph check to diff a proposed change against the published graph before it lands.
# supergraph.yaml
federation_version: =2.9.0
subgraphs:
accounts:
routing_url: https://accounts.internal/graphql
schema:
file: ./accounts/schema.graphql
orders:
routing_url: https://orders.internal/graphql
schema:
file: ./orders/schema.graphql
# Compose locally — fails on any reference/ownership error
rover supergraph compose --config supergraph.yaml --output supergraph.graphql
# Gate the PR: diff this subgraph against the registry
rover subgraph check "$APOLLO_GRAPH_REF" \
--name orders \
--schema ./orders/schema.graphql
Make the check a required status on the orders and accounts repos so neither team can merge a reference change that breaks the supergraph. Wiring these into pipelines is covered end to end in Federated Schema Validation in CI/CD Pipelines.
Performance & Scale Considerations
Reference depth costs network hops. Every subgraph boundary a query crosses is at least one HTTP round-trip. A query that traverses four subgraphs can carry 150–300 ms of baseline overhead in a typical cloud network before any business logic runs. Keep reference chains shallow; denormalise a hot, slow-changing field into the consuming subgraph, or use @provides to inline it rather than paying the hop.
The _entities batch is your N+1 boundary. The router already groups stubs of the same type into one _entities call. The N+1 reappears inside your __resolveReference if you query the database per entity — which is exactly what the Step 3 DataLoader prevents. Always index the column behind your @key; a non-indexed key turns the batch query into a full scan.
Caching is opt-in. Federation does not cache references for you. Add Cache-Control at the subgraph level for read-heavy entities such as Customer and Product (a 60–300 s max-age with stale-while-revalidate absorbs spikes), or run an entity response cache in the router. Never cache references for mutable entities without an explicit invalidation hook. Routing-layer tuning that affects these costs is covered in Gateway Routing Strategies for Federated APIs.
Failure Modes & Debugging
UNRESOLVABLE_KEY_FIELD / field ownership errors during compose. Composition fails with a message naming a field declared in two owning subgraphs, or a referencing subgraph that forgot @external. Root cause: ambiguous ownership. Fix by marking the key @external on the referencing side and resolvable: false if that subgraph does not hydrate the entity. Confirm with a re-run of rover supergraph compose.
__resolveReference returns null, field comes back null. The owning subgraph found no row for the key, so the router propagates null up the tree (and bubbles to the nearest nullable parent). Root cause: stale or wrong key value from the consuming subgraph, or a deleted row. Fix by validating that the key the consumer emits matches the @key definition exactly, and decide whether the field should be nullable or backed by a fallback — see Entity Resolution Fallback Strategies for Partial Data.
Over-fetching in __resolveReference. Symptom: high DB load and bloated _entities responses. Root cause: resolver does SELECT * regardless of the selection set. Fix by fetching only the requested fields and batching via DataLoader.
Frequently Asked Questions
What is the difference between an entity and a reference in GraphQL Federation?
An entity is a type one subgraph owns, defined with a @key and its full fields. A reference is a key-only stub of that entity — typically { __typename, id } — that a non-owning subgraph returns so the router can hydrate the rest from the authoritative subgraph via _entities.
Do I need to implement __resolveReference in every subgraph that mentions a type?
No. Only the owning subgraph implements __resolveReference. A subgraph that merely references the entity declares it with @key(resolvable: false) and the key field @external, and returns just the key from its own resolvers.
How do I avoid N+1 queries when references resolve?
The router batches same-type stubs into one _entities call, but the database N+1 reappears inside __resolveReference if you query per entity. Wrap the lookup in a per-request DataLoader so the whole batch becomes a single indexed query.
Can two subgraphs own the same field on a referenced type?
Not by default — composition rejects duplicate ownership to force a source-of-truth decision. If overlap is genuinely correct, mark the field @shareable in every subgraph that resolves it.
When should I use @provides instead of a normal reference?
Use @provides on a hot read path where the consuming subgraph already has, or can cheaply cache, a specific external field and you want to avoid the extra _entities hop for it. Keep it scoped to the few fields that justify the added composition complexity.
Related
- Handling Circular Dependencies in GraphQL Federation — breaking bidirectional reference loops
- Defining Subgraph Boundaries for Microservices — deciding who owns what
- Type Ownership and Shared Schema Contracts — governing shared types
- GraphQL Federation Architecture & Design — parent section
- Implementing Entity Resolvers with @key Directives — the resolver mechanics behind references
- Batching Entity Resolution with DataLoader — scaling reference resolution