Adding Interface Implementations in a New Subgraph

Adding a type that implements an existing interface looks like a purely local change — one team, one schema, one deploy — and it is the abstract-type change most likely to surprise somebody. Composition succeeds, no check turns red, and a new concrete type starts appearing in result lists that clients believed were closed. This page covers the safe sequence, what each participant must do, and how to verify nothing downstream breaks. It sits under Interfaces and Unions Across Subgraphs.

When to use this pattern

  • Use it when a new domain concept genuinely belongs in an existing abstract result — a new searchable thing, a new notification kind, a new payable item.
  • Use it when the owning team is new to the graph, since a fresh subgraph implementing an existing interface is the most common way a graph grows.
  • Skip it when the new type does not share the interface’s meaning. Implementing an interface to get into a result list, rather than because the type belongs there, produces a feed clients cannot render coherently.

Prerequisites

What actually changes for everyone else

Four participants are affected by what looks like one team’s change, and only the first is obvious.

The new subgraph declares the interface and the type, implements __resolveReference for its key, and deploys. This is the visible work and the smallest part of it.

The subgraph that returns the abstract field — the search service, the feed service — must be able to return references to the new type. If it maps a discriminator to a type name, that map needs the new entry, and if it does not know about the new type it will simply never return one. This is the step most often forgotten, and the symptom is that everything works and the new type never appears.

Any subgraph using @interfaceObject starts receiving representations carrying ids of the new type, without any change on its part. If its datasource cannot answer for those ids, it must return null rather than error — see extending interfaces with @interfaceObject.

Clients begin receiving a __typename they have never seen. Clients with an exhaustive switch and no default will render nothing for those items, and in a list that reads as missing data rather than as a new feature.

One team's change, four affected participants The new subgraph declares the type. The returning subgraph must be able to point at it. Interface-object subgraphs start receiving its ids automatically. Clients receive a typename they have not seen before. Adding Video as a SearchResult implementation the new subgraph — deliberate work declares the interface and Video, adds a @key, implements __resolveReference. the returning subgraph — easily forgotten must map its discriminator to "Video", or the type is never returned at all. @interfaceObject subgraphs — affected without acting start receiving Video ids immediately; must answer null rather than error. clients — the change nobody reviewed receive a new __typename; an exhaustive switch with no default renders nothing.

Implementation walkthrough

The new subgraph declares the interface exactly as the others do. Copying it verbatim matters: a field with a different nullability is a composition error, and a missing field is one too.

# video/schema.graphql — the new service
extend schema
  @link(url: "https://specs.apollo.dev/federation/v2.9", import: ["@key"])

# Verbatim copy of the interface as declared by its owning subgraph.
interface SearchResult {
  id: ID!
  title: String!
}

type Video implements SearchResult @key(fields: "id") {
  id: ID!
  title: String!
  durationSeconds: Int!
  thumbnailUrl: String!
}
// video/resolvers.ts
export const resolvers = {
  Video: {
    __resolveReference: (ref: { id: string }, ctx: Context) =>
      ctx.loaders.video.load(ref.id),
  },
};

The returning subgraph then needs two edits, and forgetting either produces a different silent outcome. Its stub list gains the new type, so it can point at one:

# search/schema.graphql
type Video @key(fields: "id", resolvable: false) { id: ID! }

And its type resolution learns the new discriminator:

const TYPE_BY_KIND = {
  PRODUCT: "Product",
  ARTICLE: "Article",
  VIDEO: "Video",      // without this, videos are indexed and never surfaced
} as const;

Order matters across those deploys. Ship the new subgraph first so the type exists and is resolvable, then the returning subgraph’s mapping so it starts producing references. Reversing that order means the search service points at a type nothing can resolve, and every result of that kind comes back null.

Verification steps

Compose first, then verify each participant in turn.

rover subgraph check my-graph@current --name video --schema ./video/schema.graphql
rover supergraph compose --config supergraph.yaml | grep -c "implements SearchResult"   # now 4

Confirm the new type is actually reachable through the abstract field, which is the check that catches the forgotten mapping:

query { search(q: "tutorial") { __typename ... on Video { id durationSeconds } } }
# expect at least one "Video" — an empty result means the discriminator map was missed

Then confirm the interface-object subgraphs cope. Send them a representation for a Video id directly and assert a null-or-value response rather than an error. And finally, exercise a client build that predates the change against the new supergraph: it should render the results it knows and degrade gracefully on the new one. If it renders a blank row, that is your answer about whether the change can ship before a client release.

Deploy order, and what each reversal costs Deploying the new subgraph before the returning subgraph's mapping is safe. Reversing the order produces references to a type nothing can resolve, so those results come back null. Every intermediate state should be shippable correct order 1. deploy video 2. map in search 3. clients reversed 1. map in search references to a type nothing can resolve → nulls The correct order has no broken intermediate state: after step one the type exists and is simply never returned, which is invisible to everyone. Removing an implementation runs the same sequence backwards for the same reason.

It also helps to know which of these steps is reversible, because that determines how much of the sequence needs a plan and how much can simply be tried.

Which steps you can take back Deploying the new subgraph and tagging it are freely reversible. Producing references to the new type is reversible with a delay. A client release that assumes the type is not reversible on your schedule. Reversibility falls off as you go step how easily undone deploy the new subgraph freely — nothing references it yet tag it into contracts freely — remove the tag start returning references with a delay — stop, then wait for traffic to drain ship a client that depends on it not on your schedule at all

Common mistakes & gotchas

Declaring the interface with a subtly different signature. A nullable title where the others have title: String! is a composition error, and the message names the type rather than the difference. Copy the interface, do not retype it.

Forgetting the discriminator mapping in the returning subgraph. Everything composes, every check passes, and the new type never appears in a single result. Assert its presence in a test rather than trusting the deploy.

Assuming interface-object subgraphs are unaffected. They start receiving your ids the moment composition succeeds, with no change and no notice on their side. Tell them, and confirm their behaviour for an unknown id is null rather than an error.

Frequently Asked Questions

Does adding an implementation require a coordinated release?

Not if you order the deploys as above — each intermediate state composes and serves. What it does require is communication, because the client-visible effect lands the moment the returning subgraph starts producing the new type, and no schema check will tell a client team that is coming.

Can the new subgraph declare only some of the interface’s fields?

No. An implementation must satisfy the whole interface, which is precisely the coupling an interface creates. If the new type genuinely cannot provide a field, that is evidence the interface is too specific — or that this type does not belong in it.

What if the new type uses a different key?

Then it cannot participate in @interfaceObject contributions, which resolve against a single key shape, and it may compose while behaving differently from its siblings. Matching the key shape used by existing implementations is worth real effort, including introducing a shared surrogate id if the natural keys differ.

How do we remove an implementation later?

In reverse. Stop the returning subgraph from producing references to it first, wait until traffic for that __typename reaches zero, then remove the type and its subgraph. Removing the type while search still points at it produces exactly the null results the reversed-order diagram shows.

Should the interface’s owning team review this change?

Yes, even though composition does not require their approval. They own what the abstraction means, and an implementation that stretches it — a type that technically satisfies the fields but is not really the same kind of thing — is how an interface stops being useful to the clients it was created for.

What should the announcement to client teams actually say?

Three facts, and no more: the new __typename value they will start seeing, roughly when it will start appearing in results, and what a reasonable fallback rendering looks like. Client teams do not need to know which subgraph owns it or how it composes; they need to know that their exhaustive switch is about to meet a case it does not handle. A short note with those three items, sent before the returning subgraph deploys rather than after, converts the most disruptive part of this change into a non-event.

Does the new implementation inherit contract tags automatically?

No. Tags are per element, so a new implementation is untagged and therefore absent from every include-list contract until someone tags it. That default is helpful — a new type does not leak into a public API by accident — but it does mean the type appears for internal clients and not for partners, which looks like a bug if nobody expected it. Tag the new type in the same change that declares it, or note explicitly that it is internal for now.