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

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

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.