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.
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.
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.
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.
Related
- Extending Interfaces with @interfaceObject — the directive in depth
- Resolving Unions Across Subgraph Boundaries —
__resolveTypeand mixed results - Adding Interface Implementations in a New Subgraph — growing the set safely
- Implementing Entity Resolvers with @key Directives — the keys these depend on
- Optimizing Reference Resolvers for Performance — batching the fetches this creates
- Subgraph Implementation & Entity Resolution — parent section