Resolving Unions Across Subgraph Boundaries

A union whose members live in different subgraphs works exactly as you would hope right up until the moment __resolveType returns something the supergraph does not recognise, and then it fails at runtime with no composition error to warn you. This page covers how the router resolves a union across services, how to write __resolveType so it cannot drift, and how to keep a mixed result list from turning into a fetch per member. It sits under Interfaces and Unions Across Subgraphs.

When to use this pattern

  • Use a union when a field genuinely returns one of several unrelated things — a search result, a payment method, a timeline entry — and clients must branch on the concrete type anyway.
  • Use it across subgraphs when the members are owned by different teams, which is the normal case once a graph has more than a few services.
  • Skip it when the members share fields clients want without branching. That is an interface, and forcing clients to write a fragment per member to read a shared title is a design smell.

Prerequisites

Who decides what a value is

Resolution splits cleanly, and knowing which half is which makes every failure easy to place.

The subgraph that returns the union decides the concrete type. Its __resolveType looks at whatever discriminator its data carries and returns a type name. This subgraph does not need to own the member types, and typically does not — a search service returns products and articles without owning either.

The subgraphs that own each member resolve its fields. Once __typename is known, the router treats each concrete type as an ordinary entity and fetches its selected fields by key.

The gap between those two halves is where the trouble lives. Composition verifies that every member is an object type and that the union is declared consistently; it cannot verify that __resolveType will ever return the right names, because that is code rather than schema. A __resolveType returning a database discriminator verbatim composes perfectly and fails on the first request.

One subgraph names the type; others fill it in The search subgraph resolves each result to a member type name and returns keys. Catalog and content then resolve the fields of their own members through separate entity fetches. union Result = Product | Article search subgraph owns neither member decides both types __typename: "Product" id only __typename: "Article" id only catalog resolves price, name content resolves headline, body The left box is the only place a wrong answer is possible, and composition cannot check it.

Implementation walkthrough

Declare the union where the returning field lives, and the members where they are owned. The returning subgraph declares stub versions of the members carrying only their keys.

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

union SearchHit = Product | Article

type Query {
  search(q: String!, first: Int = 20): [SearchHit!]!
}

# Stubs: search owns no fields on either type, only the ability to point at them.
type Product @key(fields: "id", resolvable: false) { id: ID! }
type Article @key(fields: "id", resolvable: false) { id: ID! }

resolvable: false is the detail worth pausing on. It tells composition that this subgraph can produce a reference to the type but cannot resolve one, which is exactly true for a search index. Without it, composition believes search can answer _entities queries for products, and the router will eventually send it one.

__resolveType is where correctness is decided, so make the mapping explicit and total:

const TYPE_BY_KIND: Record<string, "Product" | "Article"> = {
  PRODUCT: "Product",
  ARTICLE: "Article",
};

export const resolvers = {
  SearchHit: {
    __resolveType: (hit: { kind: string; id: string }) => {
      const typeName = TYPE_BY_KIND[hit.kind];
      if (!typeName) {
        // Fail loudly here rather than letting GraphQL report an opaque
        // "resolved to a type that does not exist" error further along.
        throw new Error(`Unmapped search hit kind: ${hit.kind} (id ${hit.id})`);
      }
      return typeName;
    },
  },
};

Two properties make that version safe. The mapping is explicit, so a new discriminator value in the index cannot silently become a type name. And the failure is a clear error naming the offending value, rather than the generic abstract-type error the executor would otherwise raise several frames later.

Clients then branch per member, selecting only what each one has:

query Search($q: String!) {
  search(q: $q) {
    __typename
    ... on Product { id name price { amount currencyCode } }
    ... on Article { id headline publishedAt }
  }
}

Verification steps

Confirm composition first, then the two runtime behaviours composition cannot see.

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

Then assert that every member resolves. A test fixture containing at least one hit of each kind, asserting __typename for each, catches the entire class of __resolveType drift:

query { search(q: "test") { __typename } }
# expect both "Product" and "Article" present in the result

Finally, count the entity fetches in the plan for a mixed result. Two members means two fetches, running in parallel; three means three. If you see more fetches than members, something is selecting fields that force an extra hop — usually a @requires on one of the member types.

The negative test is the one worth automating: feed the resolver an unmapped discriminator and assert it raises the explicit error rather than returning undefined. That test fails the day someone adds a category to the search index without adding a union member, which is precisely when you want to hear about it.

What composition catches, and what it cannot A missing key and an inconsistent union declaration are caught at composition. A wrong resolveType answer and an unmapped discriminator are runtime-only and need explicit tests. Half of these never reach a composition error failure caught by composition? what does catch it member missing a @key yes rover subgraph check union declared inconsistently yes rover subgraph check __resolveType returns a wrong name no a fixture per member new discriminator, no member no an explicit throw, plus a test

One more property is worth making explicit before shipping, because it decides how much notice client teams need. A union is additive in the schema and breaking in the client, and those two facts pull in opposite directions during review.

Additive to the schema, breaking to the client Adding a member passes composition and every schema check, while a client with an exhaustive typename switch and no default renders nothing for the new member. union SearchHit gains a third member from the schema's point of view composition: succeeds subgraph check: green usage check: nothing broke a purely additive change from a client's point of view an unrecognised __typename no matching fragment selected an item with no fields to render a blank row in a list Nothing in the pipeline reconciles those two views, which is why union growth needs a client convention — a default branch — agreed once rather than negotiated per member.

Common mistakes & gotchas

Returning the raw discriminator from __resolveType. It works while the names happen to match and breaks the moment they diverge. Map explicitly through a table so a new value is a compile-time or test-time problem rather than a production one.

Omitting resolvable: false on stubs. Without it, composition believes the returning subgraph can resolve entity references for member types it knows nothing about, and the router will eventually route one there.

Treating a new member as a purely additive change. It composes cleanly and changes runtime behaviour for every existing client that switches exhaustively on __typename. Give clients a default branch before you need one.

Frequently Asked Questions

Can a union member be an interface or a scalar?

No. Union members must be object types. That constraint exists because the router needs a concrete type to resolve fields against, and it is also why a union of two interfaces has to be modelled as a union of their implementations instead.

How do clients handle a member they do not know?

Only if you design for it. A client that switches exhaustively on __typename with no default will render nothing, or crash, when a new member appears. Selecting __typename explicitly and rendering a neutral fallback for unrecognised values costs a few lines and makes union growth genuinely additive from the client’s point of view.

Should the returning subgraph own the member types?

Usually not, and that is the whole point of federation here. A search index returns pointers to things other services own. Its stubs declare the key and nothing else, which keeps ownership where the data actually lives and means adding a field to Product never involves the search team.

Does a union cost more than an interface at runtime?

Slightly, in practice, because there are no shared fields to resolve in one place — every field selected comes from a per-member fetch. An interface can answer some selections without touching the concrete types at all. The difference is small for two members and grows with the count.

Can I filter a union by member type?

Not directly; a union has no fields to carry an argument. If clients frequently want only one member, that is a signal for a separate root field returning that concrete type, which is cheaper for them and for you than fetching everything and discarding most of it client-side.

How should a union field be paginated?

Exactly as any list field, with a cursor that does not encode the concrete type. Encoding the member type into the cursor is a tempting shortcut for the returning subgraph and it makes the cursor invalid the moment membership changes — a client holding a cursor from before a new member existed will either skip results or error. Keep the cursor opaque and derived from the index’s own ordering, so union growth does not invalidate anything a client is holding.

Where should the discriminator mapping live?

In the subgraph that returns the union, next to the resolver that uses it, and nowhere else. Teams occasionally try to centralise the mapping in a shared package so several services agree on it, which sounds tidy and creates a coupling that defeats the point: the mapping is a statement about one index’s data, not about the graph. If two subgraphs both return the same union and both need a mapping, they are describing two different datasets and should own two different tables.