Contracts and Schema Variants for API Consumers

One supergraph rarely serves one audience. The same subgraphs that power your own applications usually also have to serve partners, a public API, and internal tooling — and each of those audiences should see a different slice of the graph. This guide, part of GraphQL Federation Architecture & Design, covers how contracts and schema variants let you carve those slices out of one composed graph without forking a single subgraph.

Prerequisites

Concept Deep-Dive: What a Contract Actually Is

A contract is a filtered variant of an existing supergraph. It is not a second graph, not a fork, and not a gateway plugin. You register the same subgraph schemas exactly once; the registry composes them into a source variant, then applies a filter expressed as tag inclusion and exclusion lists to produce a second, smaller supergraph. That derived supergraph is published to its own variant with its own uplink, and a router pointed at it can only route the fields that survived the filter.

The filtering happens at composition time, which is the property that makes contracts trustworthy. A field excluded from a contract variant does not exist in that supergraph: it is not introspectable, not queryable, and not reachable by a crafted operation. This is categorically different from hiding a field in documentation or rejecting it at the resolver, because there is no code path in the contract router that knows the field ever existed.

Two directives drive the filter. @tag(name: "...") attaches an arbitrary label to a field, type, enum value, or argument; a schema element can carry several tags. @inaccessible removes an element from the API schema entirely while keeping it available to the router for internal use, which is how you keep a field that composition needs but clients must never see. Tags are the vocabulary a contract filter speaks; @inaccessible is the blunt instrument for elements no audience should ever reach.

# catalog subgraph
extend schema
  @link(url: "https://specs.apollo.dev/federation/v2.9",
        import: ["@key", "@tag", "@inaccessible"])

type Product @key(fields: "id") @tag(name: "public") {
  id: ID!            @tag(name: "public")
  name: String!      @tag(name: "public")
  price: Money!      @tag(name: "public")
  # partners see cost, the public API does not
  wholesaleCost: Money! @tag(name: "partner")
  # nobody outside this graph should see it, ever
  internalMargin: Float! @inaccessible
}

The mental shift that makes contracts click is this: you are not deciding what to expose, you are deciding what each audience’s schema is. A contract variant is a real, complete, composable GraphQL schema with its own evolution history — one you can run checks against, publish to, and roll back independently.

One composition, three supergraphs Subgraph schemas are published once and composed into a source supergraph. Tag-based filters derive a partner variant and a smaller public variant from it, each published to its own uplink and served by its own router pool. Publish once, derive many subgraphs tagged once, published once source variant every field, internal consumers only partner contract include: public, partner own uplink, own routers public contract include: public only smallest surface area A field missing from a contract is missing from that supergraph — not hidden, not guarded, absent.

Directive & Filter Spec Table

Element Where it goes Syntax Composition effect Runtime effect
@tag(name:) field, type, enum value, argument, interface @tag(name: "public") Recorded in the supergraph as metadata; no filtering by itself None until a contract filter references it
@inaccessible field, type, enum value, argument @inaccessible Element is removed from the API schema in every variant Unreachable by any client, still usable by the router
Contract include list contract configuration ["public"] Only tagged elements (and their required ancestry) survive Defines the variant’s whole surface
Contract exclude list contract configuration ["experimental"] Removes tagged elements even if an include tag also matches Exclusion always wins over inclusion
@key on a filtered type subgraph SDL @key(fields: "id") Key fields are retained automatically if the type survives Entity resolution keeps working in the contract

Step-by-Step Implementation

Step 1 — Decide the audiences before you tag anything

Tags are cheap to add and expensive to change, because every contract that references a tag depends on its meaning staying stable. Start by naming the audiences, not the fields: public, partner, internal. Resist tags that describe a field’s nature (sensitive, beta) rather than its audience — the first kind composes into clean filters, the second turns into a set of ad-hoc rules that nobody can evaluate without reading every subgraph.

Step 2 — Tag the smallest surface that is genuinely useful

Add the audience tag to the types and fields that audience needs, and nothing else. A contract filter keeps a type only if the type itself carries an included tag, so tagging a field without tagging its parent type silently drops both.

type Order @key(fields: "id") @tag(name: "partner") {
  id: ID!                @tag(name: "partner")
  status: OrderStatus!   @tag(name: "partner")
  lines: [OrderLine!]!   @tag(name: "partner")
  # untagged: absent from the partner contract
  internalRiskScore: Float!
}

Step 3 — Create the contract variant

# The source variant supplies the composed schema; the filter derives the contract.
rover contract publish my-graph@partner \
  --source-variant my-graph@current \
  --include-tag public --include-tag partner \
  --exclude-tag experimental

The contract variant has its own graph ref, so a router serving partners differs from the internal router by one environment variable:

# partner-router deployment
env:
  - name: APOLLO_GRAPH_REF
    value: my-graph@partner
  - name: APOLLO_KEY
    valueFrom:
      secretKeyRef: { name: apollo, key: partner-key }

Step 5 — Verify the surface is what you intended

rover graph introspect https://partner-router.example.com \
  | grep -c internalRiskScore   # expect 0

Composition Pipeline Integration

A contract adds a second composition that can fail independently of the first, and the failure mode is specific: removing a field can make another field unsatisfiable in the filtered graph even though the source graph is perfectly valid. If Order.lines is tagged for partners but OrderLine is not, composition of the contract has a field returning a type that does not exist.

Run contract composition in the same pipeline as your source check so both verdicts arrive on the same pull request:

# .github/workflows/schema.yml
- name: Check source variant
  run: rover subgraph check my-graph@current --name orders --schema ./schema.graphql
- name: Check partner contract
  run: rover contract check my-graph@partner

The second step is the one teams forget, and its absence is why a partner API breaks on a change that passed every check the author saw. Treat the contract check exactly like a client: it is a consumer of your schema with its own compatibility requirements. The broader pipeline patterns are covered in Schema Validation in CI/CD Pipelines.

How a valid source graph produces an invalid contract Order is tagged for partners and its lines field is tagged, but the OrderLine type is not. The source variant composes because everything exists. The partner contract fails because a retained field returns a removed type. Tagging a field is not enough — tag what it returns source variant — composes Order @tag("partner") { lines: [OrderLine!]! } type OrderLine { … } (no tag) Both types exist, so every field resolves to a real type. partner contract — fails Order { lines: [OrderLine!]! } retained OrderLine filtered out A retained field now returns a type that no longer exists. Tag transitively: a type, its fields, and every type those fields return.

Performance & Scale Considerations

Contracts cost nothing at request time. The filtering happened at composition, so a contract router does exactly the work any router does, against a smaller schema. If anything it is marginally faster: a smaller supergraph means a smaller routing table and fewer possible plans to consider.

The cost is operational and it is real. Each contract variant is a supergraph you must roll out, observe, and roll back independently. Three contracts mean three uplinks, three sets of dashboards, and three chances for a launch to fail in a way the others do not. Keep the count low deliberately — most organisations need two or three audiences, not one per partner. When a partner genuinely needs a bespoke surface, ask whether the difference is really about authorisation rather than schema, because a per-user difference belongs in directive patterns for cross-service authorization rather than in a variant.

The second scaling concern is tag sprawl. Every tag you introduce multiplies the number of filter combinations someone has to reason about, and tags are effectively public API: removing one changes every contract that references it. Six tags is a vocabulary; twenty is a maintenance burden nobody signed up for.

Failure Modes & Debugging

A field vanished from the public API after an unrelated change. Root cause: the field’s tag was on the field but its parent type’s tag was removed, or a new required ancestor was added untagged. The contract check catches this if you run it; without it the first signal is a partner ticket. Fix by tagging transitively and adding rover contract check to CI.

Composition of the contract fails with an unreachable-type error. Root cause: a retained field returns a filtered type, exactly as in the diagram above. Fix by tagging the returned type, or by excluding the field as well.

An @inaccessible field still appears in a subgraph’s own introspection. This is expected and not a leak in the graph, but it is a leak if the subgraph is reachable directly. @inaccessible removes the element from the API schema of the supergraph, not from the subgraph service. Restrict network access to subgraphs; the supergraph is the boundary, not the service.

The contract router serves stale fields after a filter change. Root cause: contract composition runs when the source variant or the filter changes, and a router only picks up what has been published. Confirm the contract launch completed before assuming the router is at fault, and check the variant’s launch history rather than the source variant’s.

Decision Guide: Contract, Authorization, or a Separate Graph

Three mechanisms can make a field invisible to some callers, and choosing the wrong one produces work that never ends. A contract answers “what does this audience’s API look like?” — a static question with a schema-shaped answer. Authorization answers “may this particular caller see this?” — a dynamic question with a per-request answer. A separate graph answers “is this even the same product?” — an organisational question that happens to have a technical shape.

The failure pattern is using contracts for what is really authorization. Symptoms are easy to spot: you find yourself wanting a variant per customer, or per plan tier, or per feature flag. Each of those is a per-caller decision wearing a per-audience costume, and modelling it as a variant means every new customer is a new supergraph to compose, publish, observe and roll back. Conversely, using authorization for what is really a contract leaves your public API cluttered with fields that always deny — a schema that documents capabilities nobody outside can use, which is both confusing and a disclosure of your internal model.

Question Contract variant Authorization directives Separate graph
Varies by audience individual caller product
Decided at composition time request time organisation level
Cost per new case a variant to operate a claim or policy an entire graph
Field is absent from the schema present but denied never in this graph
Right for public vs partner vs internal plan tiers, ownership, roles genuinely unrelated products

The practical rule is to count. If the number of distinct surfaces you need is small, stable, and knowable in advance, that is a contract. If it grows with your customer list, it is authorization. If it grows with your product line and shares nothing but a login, it is a separate graph.

What does the difference vary with? If the visible surface varies per individual caller the answer is authorization. If it varies per audience and the audiences are few and stable, the answer is a contract variant. If the products share nothing, the answer is a separate graph. Count the surfaces before you build one Does it vary per caller? yes — authorization scopes or policies decide per request Few, stable audiences? yes — contract variant one filter per audience no — separate graph different product

Evolving a Contract Without Breaking Its Consumers

A contract has consumers you cannot redeploy, which makes its evolution rules stricter than the source graph’s. Adding a tag is always safe: the contract gains a field, and gaining a field breaks nobody. Removing a tag is a breaking change to that audience even though the field still exists in the source graph and every internal client keeps working — which is exactly why it slips through review.

The safe sequence for removing something from a contract mirrors ordinary deprecation, with one extra step at the front. Deprecate the field in the subgraph so it is marked in every variant. Watch usage per variant, because the audience you are removing it from may be the only one still using it. Once that variant’s usage is zero, remove the tag, which drops it from the contract while the source graph keeps serving it internally. Only much later, when no variant uses it, do you delete the field from the subgraph.

Renaming a tag deserves a specific warning, because it looks cosmetic and behaves like a mass deletion. A contract filter matches tags by exact string, so renaming partner to partners in one subgraph silently removes every element in that subgraph from the partner contract, while leaving the rest of the graph intact. The resulting partner API is missing an arbitrary-looking subset of its fields and composition may still succeed. Lint tag names against a fixed allow-list, and treat that list the way you would treat a public enum.

Frequently Asked Questions

Is a contract a security boundary?

It is a strong one for schema surface and a weak one for data. A field excluded from a contract genuinely cannot be queried through that router, so it is not reachable by a crafted operation, and that is a real guarantee. What a contract cannot do is decide per user: every caller of the partner router sees the partner schema. Anything that varies by who is asking still needs authorisation directives on top.

Can a contract add fields rather than only remove them?

No, and this is a deliberate constraint. A contract is strictly a subset of its source variant, which is what keeps the “publish once” property intact. If a partner needs a field nobody else has, it has to exist in a subgraph and be tagged for that audience — the field is added to the graph and filtered into one contract, not invented at the contract layer.

How do contracts interact with deprecation?

They compose cleanly, and the combination is genuinely useful. Deprecate a field in the subgraph, watch usage on each variant separately, and remove the tag from the contract where usage has already reached zero. That lets you retire a field from the public API months before you can retire it internally, without maintaining two schemas.

Should the source variant be served to anyone?

Usually to internal clients only, and often to none at all. Treating the source variant as a real audience keeps it honest — it gets checks and observability like anything else — but it is also the variant with everything in it, so it is the one worth putting the tightest network controls around.

Can two contracts share a router deployment?

No — a router process loads one supergraph, so each contract needs its own deployment or at least its own configuration. In practice that is a feature rather than a limitation: separate deployments let you size the partner tier differently from the public one, apply different rate limits, and take one down without touching the other. If the operational overhead of a second deployment is what is stopping you, that is a strong signal the audience does not need its own contract yet.

What happens to entity keys in a filtered graph?

Key fields are retained automatically for any type that survives the filter, because without them the router could not resolve the entity at all. You do not need to tag them, and tagging them does not hurt. This is why cross-service type references keep working identically inside a contract.