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 compose fails with a circular-dependency or unsatisfiable-@requires error and names two subgraphs that reference each other.
  • You have a bidirectional relationship — classically Order.customer resolving to accounts while Customer.orders resolves 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 @requires against 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.

Cyclic versus acyclic federated reference design On the left, orders and accounts each require fields from the other, forming a cycle that composition rejects. On the right, orders carries a scalar customerId and defers Customer resolution to the router, breaking the cycle. Rejected: bidirectional @requires orders accounts each @requires the other Accepted: ID + router deferral orders router customerId scalar key, no back-edge

It is worth being precise about which circularity is fine and which is fatal, because the words sound identical. A cycle in the type graphUser.orders returning orders whose Order.customer points back at the user — is normal, expressible, and resolves fine: the router simply plans two fetches. A cycle in the fetch dependency graph is the fatal one: subgraph A cannot produce its part of the response until B has produced its part, while B is waiting on A. That is unsatisfiable, and it is what @requires creates when two subgraphs each require a field the other owns.

Type cycles are fine; fetch cycles are not On the left, User references Order and Order references User, which the router resolves as two independent fetches. On the right, a field in each subgraph requires a field from the other, so neither fetch can be scheduled first and composition fails. Two cycles that look the same and behave nothing alike Type-graph cycle — resolves normally User orders: [Order!]! Order customer: User! Two fetches, either order. The router walks the key both ways without trouble. Fetch-dependency cycle — unsatisfiable billing @requires(fields: "tier") accounts @requires(fields: "total") Neither fetch can go first, so the planner has no valid ordering at all. When composition reports a cycle, look at the @requires selections, not at your type references. The type graph is almost never the culprit.

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 ordersaccounts 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! }

The fix is always to break the mutual dependency rather than to reorder it, and there are only three ways to do that: move the computation to whichever side already has both inputs, replace one @requires with a plain key lookup the subgraph performs itself, or lift the derived field out of both subgraphs into the consumer. The table below shows what each option costs so you can pick deliberately instead of trying the first one that compiles.

Three ways out of a fetch cycle Move the computation to the subgraph that owns both inputs, replace one requires with a direct lookup inside the resolver, or compute the derived value in the client or a dedicated subgraph. Each option trades ownership clarity against extra calls. Break the dependency; do not try to order it 1. Move the computation to the side that owns both inputs Cheapest at runtime — the cycle disappears and no extra fetch is added. Costs a field moving between teams, so it needs an @override migration rather than a same-day edit. 2. Replace one @requires with a lookup inside the resolver Keeps ownership where it is. The subgraph fetches what it needs itself, so the dependency leaves the query plan and becomes a service-to-service call you must batch and monitor. 3. Lift the derived field out of both subgraphs Correct when the value belongs to neither domain. Adds a hop but removes the argument.

Verification steps

  1. Compose locally and confirm zero errors:

    rover supergraph compose --config supergraph.yaml --output supergraph.graphql

    A clean run that writes supergraph.graphql means the @requires dependency graph is acyclic.

  2. 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 }}
  3. 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 — customer is populated by the router from accounts:

    { "data": { "order": { "id": "ord_123", "customerId": "usr_456",
      "customer": { "id": "usr_456", "email": "engineer@platform.dev" } } } }
  4. Watch planner traces with APOLLO_ROUTER_LOG=trace: a healthy plan shows one Fetch to orders, then one batched _entities Fetch to 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 @requires on both directions. A bidirectional @requires is 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.

Does a nullable field break the cycle?

No, and this is the most common false hope. Nullability describes what a resolver may return; it says nothing about what the planner needs in order to call that resolver. If Order.riskScore requires Account.tier, the planner must fetch the tier before it can even attempt the risk score, whether or not the result can be null. Composition rejects the cycle at build time, long before any value exists to be null.

Can I keep the cycle and let the router resolve it lazily?

There is no lazy mode to fall back on. The query planner has to emit a total ordering of fetches before execution starts, and a mutual @requires has no valid ordering. This is deliberate: a runtime that discovered the cycle mid-execution would either deadlock or issue an unbounded number of round trips, and both are worse than a build failure.

How do I find the cycle when the error names several types?

Read only the @requires selections, and draw them as a directed graph on paper — one node per subgraph, one edge per required field, pointing at whoever owns that field. A cycle in that drawing is the error. Ignore your type references entirely while you do this; User pointing at Order pointing back at User is normal and is never the cause.