Handling Circular Dependencies in GraphQL Federation
This page shows how to break the two kinds of cycle that bite federated graphs — composition-time @requires loops and runtime infinite-resolution loops — without violating subgraph boundaries. It is a focused companion to Designing Cross-Service Type References within GraphQL Federation Architecture & Design.
When to use this pattern
Reach for the techniques here when:
rover supergraph composefails with a circular-dependency or unsatisfiable-@requireserror and names two subgraphs that reference each other.- You have a bidirectional relationship — classically
Order.customerresolving to accounts whileCustomer.ordersresolves back to orders — and certain query shapes hang, time out, or return 504s. - Two teams each want to “own” part of the same relationship and keep adding
@requiresagainst each other’s fields.
If you have not yet decided who owns each entity, fix that first; cycles are almost always a symptom of unclear ownership rather than a federation limitation.
Prerequisites
Why federation refuses cycles
Cycles show up at two distinct levels, and the fix differs for each.
Composition-time cycles occur when @requires chains form a loop: orders’ customer field @requires an accounts field that itself @requires an orders field. The composition engine builds a directed dependency graph of @requires edges and rejects any back-edge, because there is no valid order in which the router could satisfy the requirements. rover supergraph compose fails and refuses to emit supergraph SDL.
Runtime resolution loops occur even when composition succeeds: if Order.customer returns a full Customer and Customer.orders returns full Order objects, a query like order { customer { orders { customer { ... } } } } can drive the planner into an effectively unbounded fetch sequence. The schema is legal; the query shape is the problem.
The diagram contrasts the back-edge that composition rejects with the unidirectional shape that resolves cleanly through the router.
Implementation walkthrough
The reliable fix is to make the relationship unidirectional at the schema level: carry a scalar key and let the router perform the join through entity resolution, rather than each subgraph reaching into the other with @requires. The annotated SDL and resolver below break an orders ↔ accounts loop.
# orders subgraph schema
extend schema
@link(url: "https://specs.apollo.dev/federation/v2.9",
import: ["@key", "@external"])
# Reference Customer by key only — a stub, NOT a back-reference that
# requires accounts fields. resolvable:false means orders never hydrates it.
type Customer @key(fields: "id", resolvable: false) {
id: ID! @external
}
type Order @key(fields: "id") {
id: ID!
customerId: ID! # scalar key carried by orders — breaks the cycle
customer: Customer! # router resolves this from accounts via _entities
}
import { buildSubgraphSchema } from '@apollo/subgraph';
const resolvers = {
Order: {
// Return ONLY the key. No call into accounts, so no cycle.
// The router takes this stub to accounts' __resolveReference.
customer: (order: { customerId: string }) => ({ id: order.customerId }),
},
};
export const schema = buildSubgraphSchema({ typeDefs, resolvers });
The accounts subgraph stays the sole owner of Customer and implements __resolveReference (ideally DataLoader-batched). It never references Order, so there is no edge back into orders and the dependency graph is acyclic.
If a single scalar key is not enough — for example both sides legitimately need to expose the relationship — extract the shared base types into a dedicated, read-only contract subgraph that only defines @key-bearing stubs. Each domain subgraph then extends those stubs without referencing each other, so all edges point inward to the contract subgraph and never form a loop.
# contracts subgraph: thin, owns only keys
extend schema
@link(url: "https://specs.apollo.dev/federation/v2.9", import: ["@key"])
type Customer @key(fields: "id") { id: ID! }
type Order @key(fields: "id") { id: ID! }
Verification steps
-
Compose locally and confirm zero errors:
rover supergraph compose --config supergraph.yaml --output supergraph.graphqlA clean run that writes
supergraph.graphqlmeans the@requiresdependency graph is acyclic. -
Gate the change in CI so the cycle cannot return:
- name: Check orders subgraph against the registry run: rover subgraph check "$APOLLO_GRAPH_REF" --name orders --schema ./orders/schema.graphql env: APOLLO_KEY: ${{ secrets.APOLLO_KEY }} APOLLO_GRAPH_REF: ${{ vars.APOLLO_GRAPH_REF }} -
Run the previously-failing query and confirm a single hydration hop, not a runaway plan:
query { order(id: "ord_123") { id customerId customer { id email } } }Expected shape —
customeris populated by the router from accounts:{ "data": { "order": { "id": "ord_123", "customerId": "usr_456", "customer": { "id": "usr_456", "email": "engineer@platform.dev" } } } } -
Watch planner traces with
APOLLO_ROUTER_LOG=trace: a healthy plan shows oneFetchto orders, then one batched_entitiesFetchto accounts — no repeated alternation between the two services.
Common mistakes & gotchas
- Trying to fix the cycle at the gateway/routing layer. Routing configuration cannot remove a composition-time cycle; the back-edge is in the schema and must be removed there before composition. Routing-layer concerns are covered in Gateway Routing Strategies for Federated APIs, but they will not save a cyclic supergraph.
- Keeping
@requireson both directions. A bidirectional@requiresis the cycle. Replace at least one direction with a scalar key resolved through__resolveReference. - Sharing whole type definitions across both services. Copying full type bodies into both subgraphs recreates implicit back-references and ownership conflicts. Use a contract subgraph or
@key-only stubs instead.
Frequently Asked Questions
Can GraphQL Federation resolve circular type references during composition?
No. Composition requires an acyclic @requires dependency graph. Cycles must be broken explicitly — with scalar-ID deferral, a contract subgraph, or interface extraction — before rover supergraph compose will emit supergraph SDL.
Is the performance cost of switching to scalar IDs significant?
Generally no. You move the join from composition time to runtime, and with DataLoader-batched __resolveReference the router still issues a single _entities request per type. Composition and subgraph startup often get faster as a bonus.
Does Federation v2 handle cycles better than v1?
v2 gives clearer composition error messages and more flexible @key configurations, but the requirement for an acyclic @requires graph is unchanged from v1.
Related
- Designing Cross-Service Type References — parent guide on reference design
- Defining Subgraph Boundaries for Microservices — clarifying ownership to prevent cycles
- GraphQL Federation Architecture & Design — parent section
- Implementing Entity Resolvers with @key Directives — the resolver side of deferred joins