Interfaces and Unions Across Subgraphs

Abstract types are where federation stops being obvious. An interface implemented in three services, or a union whose members are owned by different teams, forces the router to answer a question a single-service graph never asks: which subgraph can tell me what this object actually is? This guide, part of Subgraph Implementation & Entity Resolution, covers how interfaces and unions compose, what @interfaceObject changes, and how to keep the resulting query plans from multiplying.

Prerequisites

Concept Deep-Dive: Resolution Is a Two-Step Problem

For a concrete type the router knows what it has. For an abstract type it does not, and resolution splits into two steps that can happen in different services.

Step one: determine the runtime type. Only a subgraph that owns the object can say whether this SearchResult is a Product or an Article. That subgraph’s __resolveType returns a type name, and the __typename travels in the response.

Step two: resolve the selected fields for that type. Once the concrete type is known, ordinary entity resolution applies — the router uses the @key for that type to fetch whatever other subgraphs contribute.

The consequence is that an abstract type is only as federated as its members are. A union of three entity types is three independent resolution paths, and a selection touching all three can produce three separate entity fetches. That is not a defect; it is the direct cost of asking for three different things in one field.

Determine the type, then resolve each type separately The search subgraph resolves the union member type for each result. The router then issues one entity fetch per concrete type present in the response, so a mixed result list produces several fetches. search(q: "shoes") returns a mixed list search subgraph __resolveType per item returns keys only 7 × Product __typename resolved 3 × Article __typename resolved 2 × Collection __typename resolved _entities → catalog _entities → content _entities → merchandising Three concrete types in one list means three fetches — they run in parallel, but they all happen.

Directive & Composition Spec Table

Element Where Syntax Composition-time effect Runtime effect
interface subgraph SDL interface Node { id: ID! } Every implementing type must declare every field Field resolution per concrete type
implements subgraph SDL type Product implements Node Implementations may live in different subgraphs Determines which _entities fetch applies
@interfaceObject subgraph SDL type Node @key(fields:"id") @interfaceObject Lets a subgraph add a field to all implementations One fetch instead of one per type
union subgraph SDL union Result = Product | Article Members must be object types, resolvable by key __resolveType decides the branch
__resolveType resolver map returns a type name string Not visible to composition Wrong answer produces a runtime error
@key on implementations subgraph SDL @key(fields: "id") Required for cross-subgraph contribution Enables the per-type entity fetch

Step-by-Step Implementation

Step 1 — Declare the interface where its identity lives

# content subgraph — owns what it means to be searchable
extend schema
  @link(url: "https://specs.apollo.dev/federation/v2.9", import: ["@key"])

interface SearchResult {
  id: ID!
  title: String!
}

type Article implements SearchResult @key(fields: "id") {
  id: ID!
  title: String!
  body: String!
}

Step 2 — Implement it in the subgraph that owns each concrete type

# catalog subgraph
interface SearchResult {
  id: ID!
  title: String!
}

type Product implements SearchResult @key(fields: "id") {
  id: ID!
  title: String!
  price: Money!
}

Both subgraphs declare the interface. That repetition is required: an implementing type must satisfy the interface in the subgraph that declares it, so the interface definition has to be visible there.

Step 3 — Resolve the concrete type

export const resolvers = {
  SearchResult: {
    // Return a type name the supergraph knows. Returning a name that is not an
    // implementation is a runtime error, not a composition error.
    __resolveType: (obj: { kind?: string }) =>
      obj.kind === "ARTICLE" ? "Article" : "Product",
  },
};

Step 4 — Add a shared field once, with @interfaceObject

When a subgraph has a field that applies to every implementation — a view count, a relevance score, a moderation state — declaring it against each concrete type is repetitive and produces one entity fetch per type. @interfaceObject lets that subgraph treat the interface itself as an entity:

# analytics subgraph — knows nothing about Product or Article
extend schema
  @link(url: "https://specs.apollo.dev/federation/v2.9",
        import: ["@key", "@interfaceObject"])

type SearchResult @key(fields: "id") @interfaceObject {
  id: ID!
  viewCount: Int!
}

The analytics subgraph never mentions the concrete types. The router resolves viewCount for any implementation through a single entity fetch against SearchResult, and adding a fourth implementation later requires no change here at all.

Composition Pipeline Integration

Abstract types generate a distinct family of composition errors, and they are worth recognising by shape rather than by message. The most common is an implementation that satisfies the interface in one subgraph and not another — usually because a field was added to the interface and only one team updated. The second is an implementing type without a @key, which composes only if no other subgraph ever contributes to it. The third is @interfaceObject on a subgraph that also declares the concrete types, which is contradictory: the whole point is that the subgraph does not know them.

rover subgraph check my-graph@current --name analytics --schema ./analytics/schema.graphql

Run the check on every subgraph that declares the interface, not only the one you changed. An interface field added in the content subgraph is a change to the contract every implementation must satisfy, and the failure will appear in the catalog subgraph’s composition rather than in yours. The workflow for coordinating that kind of multi-subgraph change is in federated schema validation in CI/CD pipelines.

One declaration, or one per implementation Declaring a shared field against each concrete type requires the analytics subgraph to know every implementation and produces one entity fetch per type. Declaring it once against the interface with interfaceObject requires no knowledge of implementations and produces a single fetch. Adding viewCount to every SearchResult per concrete type type Product { viewCount: Int! } type Article { viewCount: Int! } type Collection { viewCount: Int! } analytics must know every type, and a fourth one means a change here. three entity fetches for a mixed list with @interfaceObject type SearchResult @interfaceObject { id: ID! viewCount: Int! } analytics never names a concrete type, so new implementations are free. one entity fetch for the whole list The right column is better on both axes, which is unusual — most trade-offs are not this one-sided.

Performance & Scale Considerations

The cost model for abstract types is simple once stated: each concrete type present in a result is a separate resolution path. A homogeneous list of one hundred Product values costs one entity fetch. A mixed list of one hundred results spanning three types costs three, one per type, each carrying its share of the representations. They run concurrently, so wall-clock cost is roughly one fetch, but the load on your subgraphs is three times the fetch count.

That makes two levers worth knowing. @interfaceObject collapses a shared field from N fetches to one, which is the single largest win available. And selection shape matters more than usual: a query that selects only interface fields resolves without touching per-type subgraphs at all, while one that selects a ... on Product fragment forces the catalog fetch even if only two of a hundred results are products.

Unions have one extra hazard. Adding a member to a union is a schema-additive change that composes cleanly, but it changes the runtime behaviour of every existing query against that field — clients that switch exhaustively on __typename now encounter a case they do not handle. Treat union membership as a client-visible change even though no check will flag it.

Modelling: When an Abstract Type Earns Its Place

Abstract types are expensive in a federated graph — in composition coordination, in query-plan width, and in the amount clients must understand — so it is worth being deliberate about when one is genuinely the right model rather than a reflex.

The strongest case is a heterogeneous list a client renders uniformly: a search page, a notification feed, an activity timeline. The client wants a title and a link for every item and does not care what each one is. Here an interface earns its keep, because the alternative — one root field per type and client-side merging — pushes ordering and pagination into every client, which they will each get subtly wrong.

The second good case is a field whose type genuinely varies by data, such as a payment method that may be a card, a bank transfer, or store credit. These share almost nothing, so a union is honest about the branching that any client must do anyway.

The weak case, and by far the most common mistake, is using an interface as a shared field list — inheritance by another name. If Node exists only so that four unrelated types can all have an id, it buys nothing at runtime and costs a composition dependency between four teams. Every field added to that interface becomes a coordinated change across every implementation, and the interface is never actually queried abstractly. A convention that all entities have an id achieves the same outcome with none of the coupling.

The third case worth naming is the premature union: two types today, five expected later. Unions are additive in the schema and breaking in practice, because clients that switch exhaustively on __typename meet an unhandled case each time you add a member. If growth is expected, design the client contract for an unknown default from day one — an interface with a sensible fallback rendering, rather than a union that silently expands.

When an abstract type is worth its cost A uniformly rendered heterogeneous list wants an interface. A genuinely varying value wants a union. A shared field list wants a convention rather than an interface. A pair of types expected to grow wants an interface with a fallback rather than a union. Four situations, three different answers situation use because mixed list rendered uniformly interface ordering stays on the server value genuinely varies by data union clients must branch anyway shared field list only neither — a convention it is inheritance, not polymorphism two types, more expected interface + fallback growth breaks exhaustive clients The third row is the one that quietly couples four teams for no runtime benefit at all.

Evolving an Abstract Type Across Teams

Because an interface is a contract every implementation must satisfy, changing one is the most coordination-heavy change in a federated schema — more so than changing a shared entity, because the obligation falls on teams who may have no interest in the change.

Adding a field to an interface is the case worth having a procedure for. Done naively — field added to the interface in one pull request — composition fails immediately for every subgraph whose implementation lacks it, and the graph cannot ship until every team responds. The safe order inverts that: add the field to each concrete type first, in whatever order those teams can manage, and add it to the interface only once every implementation already has it. Each intermediate state composes, no team is blocked by another, and the final change is a one-line edit that cannot fail.

Removing a field runs the same sequence backwards. Remove it from the interface first, which is safe because implementations may have fields the interface does not require, then let each team remove it from their concrete type on their own schedule. Reversing that order breaks composition the moment the first team acts.

Adding an implementation is comparatively easy and is covered in detail in adding interface implementations in a new subgraph. The one thing to remember is that it is a client-visible change even though it is schema-additive: a new concrete type appears in existing result lists, and clients that assumed a closed set will meet something they do not render.

What makes all three manageable is deciding early which subgraph owns the interface definition. Without an owner, four teams each hold a copy of the same declaration and it drifts; with one, the others treat it as an imported contract and there is somewhere for the conversation about changing it to happen. The ownership question is the same one covered in type ownership and shared schema contracts, and abstract types are where getting it wrong hurts most.

Failure Modes & Debugging

Abstract type "SearchResult" was resolved to a type "Foo" that does not exist inside the schema. Root cause: __resolveType returned a name that is not an implementation — usually a database discriminator returned verbatim. Map explicitly rather than passing through.

A field is missing on some results but not others. Root cause: the field is declared on one implementation and not another, and the client selected it inside a type-conditional fragment. Check the interface definition in every subgraph that declares it.

Composition rejects @interfaceObject. Root cause: the same subgraph also declares one or more concrete implementations. A subgraph uses @interfaceObject precisely because it does not know them; declaring both is contradictory.

Query plans balloon after adding one union member. Root cause: an additional concrete type means an additional entity fetch on every mixed result. Consider whether the new member genuinely belongs in the union or wants its own field.

Testing Abstract Types

Abstract types fail in ways ordinary integration tests miss, because the interesting cases only appear when a single response contains more than one concrete type. Three tests are worth writing deliberately.

The first asserts that every implementation resolves. Query the abstract field with a fixture containing at least one instance of each concrete type and assert the __typename of each. This catches a __resolveType that returns a name the supergraph does not know, which is a runtime error rather than a composition error and therefore invisible until it happens in production.

The second asserts the shape of the plan for a mixed result, not just the data. Capture the query plan for a selection containing type-conditional fragments and assert the number of entity fetches. A plan that grows a fetch when someone adds a union member is exactly the regression you want to hear about in a pull request, and it is silent in a data-only assertion because the response looks identical.

The third asserts behaviour for an unknown concrete type. Add a member to the union in a test schema and confirm the client-facing behaviour is a sensible fallback rather than a crash or a blank. This is the test that turns “adding a member is additive” from a hopeful statement into a verified one.

Interface fixtures are also worth centralising. Because every subgraph declaring an implementation must satisfy the same interface, a shared fixture that asserts the interface’s field set is a cheap way to catch drift — one small test in each subgraph repository, all reading the same expected shape, failing in whichever repository fell behind.

Frequently Asked Questions

Should I prefer an interface or a union?

Use an interface when the members share fields clients will select without knowing the concrete type, which is what makes a search or feed usable. Use a union when the members share nothing but a position in the schema, and clients must branch anyway. Reaching for a union to avoid agreeing on shared fields tends to push that agreement into every client instead.

Can implementations live in different subgraphs?

Yes, and that is the normal case in a real graph. Each implementing type is owned by whichever subgraph owns that concept, and each needs a @key if other subgraphs contribute fields to it. What must be consistent is the interface definition itself, which is duplicated in every subgraph that declares an implementation.

Does @interfaceObject work with unions?

No. It is specific to interfaces, because it depends on the interface being a type in its own right that can carry a key. A union has no fields of its own, so there is nothing for a contributing subgraph to attach to. The equivalent for a union is declaring the field on each member.

How do I add a field to an interface safely?

As a coordinated change, because every implementation must satisfy it. Add the field to the interface and to all implementations in one composed release, or introduce it on the concrete types first and add it to the interface once every implementation has it. The second order composes at every intermediate step, which makes it the safer of the two.

Do abstract types work inside contracts?

Yes, with the usual transitive caveat: if a filter removes one implementation of an interface, the interface survives with fewer implementations, and a client fragment naming the removed type becomes invalid. Tag implementations together with the interface, as covered in contracts and schema variants for API consumers.