Entity Resolution Fallback Strategies for Partial Data

This guide shows how to keep a federated query returning a complete, type-safe response when a subgraph hands back partial data for a referenced entity — using fallback identifiers, default injection, and stale-cache recovery. It sits under optimizing reference resolvers for performance within Subgraph Implementation & Entity Resolution.

When to Use This Pattern

  • A downstream subgraph intermittently drops non-nullable fields under load or version drift, collapsing parent objects to null.
  • Some entities are addressable by more than one identifier (a new id and a legacy key) and representations may arrive with only one populated.
  • You need the graph to stay available during a partial outage rather than failing the whole operation.

If a subgraph is fully down rather than degraded, this is a circuit-breaking problem first; fallbacks handle the partial case where some data is recoverable.

Prerequisites

Root Cause: Identifying Partial Data

Partial payloads rarely surface as HTTP errors. They appear as gateway merge failures or silent null propagation. When the router cannot satisfy a non-nullable field, it nulls the affected entity and may attach an error:

{
  "errors": [
    {
      "message": "Cannot return null for non-nullable field Product.sku",
      "path": ["catalog", "product", "sku"],
      "extensions": { "code": "INTERNAL_SERVER_ERROR" }
    }
  ],
  "data": { "catalog": { "product": null } }
}

Diagnose before patching: open the trace, filter to the _entities fetch phase, and identify which subgraph drops fields. Cross-reference field nullability in the supergraph schema against the resolver path — a field marked ! in SDL that returns null at runtime is a contract violation at the source, which a fallback masks rather than fixes.

Four reasons a reference does not resolve A deleted record, a not-yet-replicated write, a datasource outage, and a malformed key all produce a null entity, but only two of them are genuine errors and each deserves a different response. Same null, four different meanings record genuinely deleted Expected. Return null and let the field's nullability carry it. not an error — do not log it as one write not yet replicated Transient. A read-your-writes path or a short retry usually resolves it. retryable — bounded, then null datasource unavailable A real failure. Returning null here hides an outage behind an empty screen. throw — it belongs in errors[] malformed or missing key A bug in the producing subgraph, not a missing record. throw — and alert on it

Implementation Walkthrough

The most robust pattern combines three layers in the reference resolver: accept an alternative identifier, inject schema-compliant defaults for missing non-key fields, and fall back to a stale cache snapshot when the primary fetch degrades. The SDL declares a secondary key as non-resolvable so the router will not dispatch _entities queries against it, while the subgraph can still receive it in a representation.

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

# resolvable: false means the router won't plan fetches keyed on legacyId,
# but representations carrying legacyId are still accepted by this subgraph.
type Product
  @key(fields: "id")
  @key(fields: "legacyId", resolvable: false) {
  id: ID!
  legacyId: String
  sku: String!
  name: String!
  warehouseId: String!
}
// catalog subgraph — resolvers.ts
import DataLoader from 'dataloader';
import CircuitBreaker from 'opossum';
import { redisClient } from './cache';

interface Product { id: string; legacyId?: string; sku: string; name: string; warehouseId: string; }
interface Context {
  db: { products: { batchGet(keys: { id?: string; legacyId?: string }[]): Promise<(Product | null)[]> } };
  config: { defaultWarehouseId: string };
}

// Primary batched fetcher, guarded by a circuit breaker.
const fetcher = async (keys: { id?: string; legacyId?: string }[]) =>
  context.db.products.batchGet(keys);

const breaker = new CircuitBreaker(fetcher, {
  timeout: 2000, errorThresholdPercentage: 50, resetTimeout: 30000,
});

// When the breaker is open, serve the last known good entity from Redis.
breaker.fallback(async (keys: { id?: string; legacyId?: string }[]) => {
  const cached = await redisClient.mget(keys.map(k => `entity:product:${k.id ?? k.legacyId}`));
  return cached.map(v => (v ? (JSON.parse(v) as Product) : null));
});

export const resolvers = {
  Product: {
    __resolveReference: async (
      ref: { id?: string; legacyId?: string },
      context: Context,
    ): Promise<Product | null> => {
      // 1. Reject a truly empty representation early.
      if (!ref.id && !ref.legacyId) {
        throw new Error('ENTITY_KEY_MISSING: no primary or fallback identifier');
      }

      // 2. Fetch via the breaker (DataLoader batches; breaker handles degradation).
      const [product] = await breaker.fire([{ id: ref.id, legacyId: ref.legacyId }]);
      if (!product) return null;

      // 3. Inject type-safe defaults for missing non-key fields.
      //    Never inject a value of the wrong scalar type into the schema.
      return {
        ...product,
        sku: product.sku ?? `LEGACY-${ref.legacyId ?? 'UNKNOWN'}`,
        warehouseId: product.warehouseId ?? context.config.defaultWarehouseId,
      };
    },
  },
};

Three things make this safe. The secondary @key(... resolvable: false) lets a representation arrive keyed by legacyId without the router trying to plan a fetch on it. The default injection substitutes values that match the SDL scalar types exactly, so the merge phase never sees a type mismatch. And the circuit breaker’s fallback serves a Redis snapshot when the database is degraded, keeping the entity resolvable during a partial outage. Coordinate the batching here with optimizing reference resolvers for performance so the fallback path does not reintroduce N+1 fetches.

The order of the three layers is not arbitrary. Identifier resolution comes first because everything downstream needs some key to work with — there is no point caching or defaulting an entity you cannot address. Default injection comes last, after the fetch, because it operates on whatever the primary or fallback path returned; injecting defaults before the fetch would mask a successful read with placeholder data. The circuit breaker wraps the fetch itself, sitting between the two, so that a degraded database transparently swaps to the cache without the identifier logic above or the defaulting logic below needing to know which path produced the row. This layering means each concern stays independent: you can change the cache backend without touching identifier handling, or tighten the breaker thresholds without revisiting the defaults.

Be deliberate about what the stale snapshot contains. The Redis fallback is only as useful as the freshness of the last successful write, so populate the cache on the success path of the breaker — write the resolved entity back to entity:product:<key> whenever the primary fetch succeeds. A cache that is only written on the fallback path is empty exactly when you need it. Pair that with a TTL long enough to outlast a typical outage but short enough that a recovered subgraph’s fresh data wins quickly, and add jittered expiry so a fleet of pods does not all refill the same key at the same instant when the breaker closes.

How far a null travels depends on the nullability above it A null entity under a nullable field nulls only that field. Under a non-null field it nulls the parent, and if that parent is also non-null the emptiness climbs until it reaches a nullable ancestor, potentially taking the whole data payload. One missing entity, two very different blast radii order.customer: Customer customer: null the order still renders order.customer: Customer! order: null orders: null the whole order disappears, then the whole list Non-null is a promise that the value always exists. For a cross-subgraph reference that promise depends on another team's data, so it is rarely one you can keep. Default entity references to nullable and reserve non-null for values you own outright.

Verification Steps

Confirm the SDL composes with the secondary key:

rover subgraph check "$APOLLO_GRAPH_REF" --name catalog --schema ./catalog/schema.graphql

Then fire a synthetic representation that omits a non-key field and verify the default is injected rather than a null collapsing the parent:

query VerifyFallback {
  product(id: "prod_404") { id sku warehouseId }
}

A healthy fallback returns a complete object, e.g. { "id": "prod_404", "sku": "LEGACY-UNKNOWN", "warehouseId": "wh-default" }, with no top-level errors. To exercise the stale-cache path, trip the breaker (force the database fetch to time out) and confirm the resolver returns the cached snapshot instead of null.

Three fallbacks, and when each is honest Returning a stub with only the key keeps the shape intact but can present invented data. Returning null is honest and requires nullable fields. Throwing surfaces the problem but costs the surrounding data if nullability is tight. A fallback is a claim about reality — choose carefully key-only stub { id } and nothing else Honest when the id is all you truly know. Dishonest the moment you invent a name like "Unknown Customer". null the field is simply absent Always honest, and the default you should reach for first. Needs the field to be nullable in the schema. throw an entry in errors[] Right for outages and for malformed keys — someone needs to be paged. Costs the surrounding data if nullability is tight.

Common Mistakes & Gotchas

Injecting a wrong-typed default. Substituting 0 into a String! field passes your resolver but fails the gateway merge with a coercion error. Match the SDL scalar exactly — a string placeholder for String!, a sane numeric default for Int!.

Ignoring @requires dependencies in the fallback. If a downstream field uses @requires, a fallback entity that omits the required source fields will make that downstream resolver fail. Populate the fields named in any @requires contract, even in the degraded path.

Synchronous or unbatched fallback fetches. A blocking cache read or a per-key fallback negates the performance work upstream and inflates tail latency. Keep the fallback asynchronous and batched, and always log partial-data incidents so the underlying contract violation gets fixed at the source.

Frequently Asked Questions

How does federation handle partial entity payloads during query planning?

The router expects each referenced entity to resolve per its @key. If a subgraph returns null for a required key or non-nullable field, that null propagates up and collapses the parent object. Fallback strategies intercept this before the merge by supplying an alternative identifier, a cached snapshot, or a typed default.

Can I use @requires with fallback resolvers?

Yes, but the fallback must still satisfy the @requires contract. If the required source fields are absent from the degraded payload, fetch them from a secondary source or inject defaults of the correct scalar type — otherwise the dependent resolver fails downstream.

Should a fallback return partial data or block the query?

Return a complete, type-safe shape using cached or default values. Blocking the operation on partial data degrades the client experience and defeats federation’s fault tolerance. Always log the incident so the source subgraph’s contract violation is remediated.

Should a missing entity be logged?

Log the ones that are unexpected and stay quiet about the ones that are not. A reference to a deleted record is normal traffic; logging it produces noise that buries the cases that matter. A malformed key or a datasource failure should be logged with the key and the subgraph that produced the reference, because both indicate a bug someone can fix.

Can the router retry a failed entity fetch for me?

It can retry the HTTP request, not the individual lookup. Router-level retries help with transport failures and are configured per subgraph in traffic shaping; they do nothing for an entity that resolved to null, because from the router’s point of view that request succeeded. Retry logic for a genuinely transient miss belongs inside your resolver, bounded and short.