Using Compound and Multiple @key Directives

@key(fields: "tenantId sku") and two separate @key directives look similar and mean opposite things. One says an entity is identified by several fields together; the other says it can be found through several different doors. Confusing them produces either a resolver that cannot find anything or a graph with entry points nobody implemented. This page covers both forms, when each is right, and what they cost. It sits under Implementing Entity Resolvers with @key Directives.

When to use this pattern

  • Use a compound key when identity genuinely requires several fields — a per-tenant SKU, a versioned document, a line within an order.
  • Use multiple keys when different subgraphs hold different identifiers for the same thing, and each needs a way in.
  • Skip both when a single opaque id will do. It is cheaper on the wire, cheaper to change, and cheaper to reason about, and most entities want exactly one.

Prerequisites

Two directives that read alike

A compound key is one identifier made of several fields. @key(fields: "tenantId sku") declares that neither field identifies the entity alone; the pair does. Every representation carries both, and the reference resolver receives both and must use both.

Multiple keys are alternative identifiers. Two @key directives on one type declare two independent ways to find it. The router may use either, depending on which fields the plan already has in hand, and the reference resolver must handle both shapes — receiving one or the other, never both.

The runtime difference is concrete. A compound key makes representations larger, because both fields travel with every entity in every fetch. Multiple keys make the resolver branchier, because it must inspect which fields arrived. And a compound key is a single door; multiple keys are several, each of which must actually open.

One door with two locks, or two doors A compound key sends both fields in every representation and needs one lookup. Multiple keys send whichever field the plan has and need a lookup per shape, with the resolver branching on what arrived. Same syntax family, opposite meanings compound — one door, two locks @key(fields: "tenantId sku") every representation carries both one lookup, no branching cost: bigger payload per entity multiplied by list length multiple — two doors @key(fields: "id") @key(fields: "sku") whichever field the plan has resolver branches on what arrived cost: a lookup path per shape each needing its own loader Both are correct answers to different questions; neither is a default worth reaching for.

Implementation walkthrough

A compound key is the simpler resolver, because there is only ever one shape to handle.

# catalog subgraph — a SKU is only unique within a tenant
extend schema
  @link(url: "https://specs.apollo.dev/federation/v2.9", import: ["@key"])

type Product @key(fields: "tenantId sku") {
  tenantId: ID!
  sku: String!
  name: String!
}
export const resolvers = {
  Product: {
    // Both fields always present. The loader key must combine them, or
    // two tenants' identical SKUs collide in the per-request cache.
    __resolveReference: (ref: { tenantId: string; sku: string }, ctx: Context) =>
      ctx.loaders.productByTenantSku.load(`${ref.tenantId}:${ref.sku}`),
  },
};

Multiple keys need a resolver that inspects what arrived, and a loader per shape so each path batches independently:

type Product @key(fields: "id") @key(fields: "sku") {
  id: ID!
  sku: String!
  name: String!
}
export const resolvers = {
  Product: {
    __resolveReference: (ref: { id?: string; sku?: string }, ctx: Context) => {
      // Exactly one shape arrives per representation — never assume which.
      if (ref.id) return ctx.loaders.productById.load(ref.id);
      if (ref.sku) return ctx.loaders.productBySku.load(ref.sku);
      // A representation matching no declared key is a bug in the producing
      // subgraph. Fail loudly rather than returning null.
      throw new Error(`Product reference matched no key: ${JSON.stringify(ref)}`);
    },
  },
};

The two loaders matter more than they look. Sharing one loader across both shapes means an id and a SKU landing in the same batch, and a batch function that cannot tell which is which. Separate loaders keep each batch homogeneous and each query simple.

Verification steps

Test each key shape independently, because the router will use whichever the plan happens to have and a shape you never exercised will eventually be chosen.

# Exercise every declared key directly through _entities.
query {
  _entities(representations: [
    { __typename: "Product", id: "P-1" },
    { __typename: "Product", sku: "SKU-9" }
  ]) { ... on Product { name } }
}

Both must return data. A null from either means that door is declared and not implemented, which composes fine and fails at runtime the first time the planner prefers it.

For a compound key, verify the loader key actually combines the fields:

// Two tenants, the same SKU. If this test returns the same product twice,
// the loader is keyed on sku alone and is leaking data across tenants.
expect(await resolve({ tenantId: "T-1", sku: "S" })).not.toEqual(
  await resolve({ tenantId: "T-2", sku: "S" }),
);

That is the single most important test on this page. A compound key with a single-field loader key is a data-isolation bug that passes every composition check, returns plausible data, and is invisible until a customer sees another customer’s product.

What each key shape costs A single opaque key is smallest and simplest. A compound key grows every representation. Multiple keys add a lookup path and a loader per shape, and every declared key must stay implemented forever. Prefer the cheapest shape that is actually correct shape runtime cost maintenance cost single opaque id smallest payload one lookup, forever compound key N fields × list length one lookup, composite cache key multiple keys same as single, per shape every door must stay open Removing a declared key is a breaking change to the graph even though no client selected it, because some subgraph may be producing references in exactly that shape.

It is worth stating the design preference plainly, because both forms on this page are easier to add than to remove. A single opaque identifier issued by the owning subgraph is the right answer for the large majority of entities, and the reasons compound over time.

It keeps representations small, which matters most on exactly the queries that already cost the most — a list of two hundred entities carries two hundred copies of whatever the key contains. It keeps the reference resolver a single line with no branching, which means there is no path that testing can miss. It survives business change, because an opaque id has no meaning that can become wrong when a SKU scheme is revised or a tenant is merged. And it makes the whole class of satisfiability error impossible, since there is only ever one shape for producers and resolvers to agree on.

Compound keys are correct when identity genuinely is composite and no surrogate exists — and even then, introducing a surrogate is often the cheaper long-term move. Multiple keys are correct when a subgraph you do not control holds a different identifier and cannot obtain yours. Outside those two cases, the extra shape is usually convenience for one team paid for by everyone who touches the entity afterwards.

A short checklist covers every key shape, and running it before declaring anything is considerably cheaper than discovering a gap through a satisfiability error.

Four checks per declared key Every key shape needs a lookup, a complete loader key, a test, and agreement with producers. Before declaring any key check compound multiple a lookup exists for this shape one one per key the loader key includes every field essential per shape a test exercises this shape one one per key producers declare the same shape required required for each The second row is the data-isolation one: a partial loader key silently mixes entities that should not meet. The fourth row is what composition checks for you; the other three are entirely yours.

Common mistakes & gotchas

A compound key with a single-field loader key. The most dangerous mistake on this page, because it silently crosses tenant boundaries. Compose every field of the key into the cache key.

Declaring a second key nobody resolves. It composes, the planner may choose it, and the entity comes back null. Every declared key needs a branch in the resolver and a test.

Assuming both keys arrive together. With multiple keys the router sends one shape per representation. A resolver reading ref.id unconditionally works until the planner takes the other door.

Frequently Asked Questions

Can a compound key include a nested field?

Yes — @key(fields: "org { id } sku") is valid, and the representation carries the nested shape. It works and it makes every representation larger and every loader key more awkward to build. If the nested value is stable, denormalising it into a flat field on the entity is usually worth doing for this reason alone.

When is a second key genuinely worth it?

When another subgraph legitimately holds a different identifier and cannot obtain yours. A legacy service that knows only SKUs, or an external integration keyed on a partner reference, are the real cases. What is not a good reason is convenience for one team, because the cost of the extra door is borne by whoever maintains the resolver forever.

Do multiple keys make the query planner smarter?

They give it more options, which is occasionally a shorter plan and occasionally a surprise. The planner will use whichever key it can satisfy most cheaply, and that may not be the one you expected or the one you optimised. Read the plan after adding a key rather than assuming the intent carried through.

Can I change a key later?

Only by adding the new one, migrating producers to it, and then removing the old one — which is a multi-subgraph coordination exercise, because any subgraph producing references in the old shape breaks the moment it disappears. That difficulty is the strongest argument for choosing a stable, opaque identifier at the start.

How do we document which key an entity uses?

One table, shared across every subgraph repository: entity, key fields, resolving subgraph. It takes minutes to write and it is the artifact that stops a producer declaring a key nobody resolves — the mismatch becomes a row that disagrees with itself rather than a satisfiability error discovered during a release. Teams that keep this table current stop hitting key-related composition failures almost entirely, not because the rules change but because the graph-wide knowledge stops living only in people’s heads.