Extending Interfaces with @interfaceObject

@interfaceObject lets a subgraph contribute a field to every implementation of an interface without knowing that any of those implementations exist. It is the one federation directive that removes coupling rather than expressing it, and it is under-used because its shape is unusual: the subgraph declares the interface as an entity and never mentions a concrete type. This page shows exactly how to write it, what the router does with it, and where it stops working. It sits under Interfaces and Unions Across Subgraphs.

When to use this pattern

  • Use it when a field applies to every implementation of an interface and is owned by a subgraph that has no business knowing the concrete types — analytics, moderation, personalisation, pricing.
  • Use it when implementations are added frequently, so a contributing subgraph does not need a release every time a new type appears.
  • Skip it when the field only applies to some implementations. A view count that is meaningless for one member is a signal the field belongs on the concrete types instead.

Prerequisites

What the directive actually declares

Read type SearchResult @key(fields: "id") @interfaceObject literally and it looks wrong: SearchResult is an interface elsewhere, and here it is declared as an object type. That is deliberate. Inside this subgraph, SearchResult is not an interface at all — it is an ordinary entity with a key and some fields. The directive tells composition that this entity is a stand-in for the interface defined elsewhere, and that every field declared here should be attached to every implementation of it.

Three consequences follow, and each one is the answer to a question people ask on first encountering it.

The contributing subgraph never learns the implementations. It resolves references for SearchResult by id and returns the fields it owns; whether a given id is a Product or an Article is not its concern and is not visible to it.

Composition attaches the fields to every implementation automatically. Add a fourth implementation in another subgraph next quarter and it gains viewCount with no change here.

The router resolves the field with one entity fetch for a mixed list, rather than one per concrete type. That is the performance argument, and on a heterogeneous feed it is substantial.

One declaration, attached to every implementation The analytics subgraph declares SearchResult as an entity with interfaceObject and one field. Composition attaches that field to Product, Article and Collection, none of which the analytics subgraph names. analytics never names a concrete type analytics subgraph type SearchResult @interfaceObject composition attaches the field Product.viewCount Article.viewCount Collection.viewCount A fourth implementation added next quarter gains viewCount with no change in analytics at all.

Implementation walkthrough

The contributing subgraph is short, and every line of it is load-bearing:

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

# Declared as an OBJECT type, not an interface — that is what @interfaceObject
# means. This subgraph has no idea Product or Article exist.
type SearchResult @key(fields: "id") @interfaceObject {
  id: ID!
  viewCount: Int!
  lastViewedAt: DateTime
}
// analytics/resolvers.ts
export const resolvers = {
  SearchResult: {
    // An ordinary reference resolver. The representation carries only the id;
    // __typename in it will be "SearchResult", never "Product".
    __resolveReference: (ref: { id: string }, ctx: Context) =>
      ctx.loaders.viewStats.load(ref.id),
  },
};

Note what is absent: no __resolveType, no mention of any concrete type, and no branch on what kind of thing the id refers to. If the analytics store needs to know, the id must carry that information or the design is wrong for this directive.

The interface itself lives where its identity does, and every implementing subgraph declares it as usual:

# content/schema.graphql — unchanged by the analytics contribution
interface SearchResult {
  id: ID!
  title: String!
}

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

The composed supergraph then exposes viewCount on SearchResult and on every implementation, and a client selecting it against the interface gets it for every result regardless of concrete type.

Verification steps

Confirm composition attached the field where you expect:

rover supergraph compose --config supergraph.yaml > supergraph.graphql
grep -A3 "type Article" supergraph.graphql | grep viewCount   # expect a match

Then confirm the runtime behaviour that motivated the directive — a single entity fetch for a mixed list:

query {
  search(q: "shoes") {
    id
    title
    viewCount      # selected against the interface, not inside a fragment
  }
}

Capture the query plan and count the fetches to the analytics subgraph. There should be exactly one, carrying representations for every result regardless of their concrete types. Two or more means the field was declared per concrete type somewhere rather than through the interface object, which is worth finding before it multiplies.

Finally, verify the representation shape reaching the analytics subgraph. Log __typename in __resolveReference: it should read SearchResult, not Product. A resolver written expecting a concrete typename will silently mis-handle every request, and this one-line check surfaces it immediately.

Fetches for a mixed list of one hundred results Declaring the shared field on each concrete type produces one entity fetch per type present in the result. Declaring it once against the interface produces a single fetch carrying every representation. Analytics fetches for one mixed result list per concrete type Product — 62 reps Article — 27 reps Collection — 11 reps with @interfaceObject SearchResult — 100 representations, one fetch Why it matters more than the fetch count suggests Three fetches means three connections, three sets of overhead, and three chances to time out. The gap widens with every implementation added, and it widens without anyone changing the analytics subgraph — which is exactly why the per-type version degrades unnoticed.

Common mistakes & gotchas

Declaring concrete implementations in the same subgraph. Composition rejects it, and correctly: a subgraph using @interfaceObject is asserting that it does not know the implementations. If it does know them, declare the field on each type instead.

Expecting a concrete __typename in the representation. The router sends SearchResult. A resolver branching on __typename will find a value it does not expect and take the wrong path, usually silently.

Using it for a field that only some implementations have. The field appears on all of them, so implementations for which it is meaningless must return something. A nullable field with a documented meaning is acceptable; a zero that reads as real data is not.

When it applies, and what to use when it does not A field for every implementation from a type-agnostic subgraph fits. A field for some implementations, a subgraph that knows the types, and a union all need different approaches. Four situations, one of which fits situation fits? otherwise field for all, subgraph type-agnostic yes field for some implementations no declare on those concrete types subgraph owns the concrete types no just add the field normally the abstract type is a union no declare on each member

Frequently Asked Questions

Does every implementation need the same key?

Yes. The contributing subgraph resolves against the interface’s key, so every implementation must be reachable by that same key shape. An interface whose implementations are keyed differently cannot use this directive, and the workaround — a synthetic shared identifier — is usually a good idea for other reasons too.

Can several subgraphs use @interfaceObject on the same interface?

Yes. Each contributes its own fields, exactly as several subgraphs can contribute fields to an ordinary entity. They must agree on the key and must not declare the same field, which are the same rules that apply to any shared entity.

What happens if an implementation is added in a subgraph that does not know about the analytics fields?

Nothing needs to happen. Composition attaches the fields automatically, and the new type gains them without its owning team doing anything. That is the entire value of the directive, and it is worth stating in a review so the property is understood rather than rediscovered.

Can the contributing subgraph filter or sort by its fields?

Only within its own root fields. It cannot add arguments to a field it does not own, so “sort search results by view count” has to be expressed in the subgraph that owns the search field, which then needs the view counts. That is a real limitation and usually means the ranking belongs with the search, not with the analytics.

Is there a downside compared with declaring the field per type?

One: the contributing subgraph must be able to answer for any id in the interface, including ids of types it has never heard of. If your analytics store only tracks products, an interface-object field will be asked about articles too and must have a defined answer. Returning null is fine; erroring is not.

How does this interact with contracts and filtered variants?

The interface-object field is filtered like any other field, which is usually what you want: analytics data can be present internally and absent from a partner contract without touching the concrete types at all. The subtlety is that the interface itself must survive the filter for the field to have anywhere to attach, so tagging the interface and forgetting the concrete types — or the reverse — produces a variant where the field is missing from some implementations and not others. Tag the interface, its implementations, and the interface-object type together.

Can a field declared this way be deprecated?

Yes, and it deprecates everywhere at once, which is one of the nicer properties of the directive. A single @deprecated on the interface-object field marks it on every implementation, and usage reporting aggregates across all of them — so the “is anyone still selecting this?” question has one answer instead of one per concrete type.