Best Practices for Shared Enums Across Federated Services

Enum drift between independently deployed subgraphs is the failure mode that turns a one-line schema change into a broken supergraph composition, and this guide gives you the diagnostic path, the minimal viable configuration, and the CI gate that prevents it. It builds on the overview in managing shared enums across subgraphs, which lives within Subgraph Implementation & Entity Resolution — read that first if you need the composition rules before the operational practices here.

When to Use This Pattern

  • You have two or more subgraphs that declare the same enum, especially a status-style enum used as both an output field and a filter argument, where Apollo Federation v2 demands identical value sets.
  • You are seeing intermittent incompatible enum values composition errors on pull requests and want a CI gate that catches drift before it reaches the registry.
  • You are scaling past roughly five subgraphs and manual coordination of enum changes has become a deployment-ordering hazard.

Prerequisites

Implementation Walkthrough

The goal is a single canonical enum value set, declared identically in every subgraph that references it, with two automated guards: a parity check that runs in CI, and a resolver-level normalisation that stops undeclared values from ever reaching the router.

Start with the SDL. In Federation v2, enums do not use @shareable; you simply declare matching values everywhere. The diagram shows the control flow that keeps a value set aligned from the canonical definition through CI into each subgraph.

Shared-enum parity pipeline A canonical enum definition feeds a CI parity check, which gates publishing to the registry, which fans the aligned value set out to each subgraph. Canonical enum package / registry CI parity gate rover + script Registry composed schema Subgraph A Subgraph B Subgraph C

Each subgraph that declares the enum uses the identical value set. The composition engine does not merge partial declarations for an enum used in both positions — it requires complete, matching value sets.

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

# Must match exactly across every subgraph that declares OrderStatus
enum OrderStatus {
  PENDING
  PROCESSING
  SHIPPED
  DELIVERED
  CANCELLED
}

type Order @key(fields: "id") {
  id: ID!
  status: OrderStatus!
}

The resolver is where runtime drift creeps in: a database row holding shipped lower-cased, or a transitional value not in the SDL, throws at serialisation. Normalise at the boundary and reject anything unknown.

// services/orders/src/resolvers.ts
import { buildSubgraphSchema } from '@apollo/subgraph';

const VALID = new Set(['PENDING', 'PROCESSING', 'SHIPPED', 'DELIVERED', 'CANCELLED']);

function toOrderStatus(raw: string): string {
  const v = raw.toUpperCase();
  if (!VALID.has(v)) throw new Error(`Unknown OrderStatus: "${raw}"`);
  return v;
}

export const resolvers = {
  Order: {
    // Reference resolver hydrates the entity for the router
    __resolveReference: async (ref: { id: string }, ctx: Context) => {
      const order = await ctx.db.orders.findById(ref.id);
      return { ...order, status: toOrderStatus(order.status) };
    },
  },
};

Now add the CI parity check. This script extracts enum values from two SDL files and fails the build on any difference, so drift is blocked on the pull request rather than discovered at composition.

// scripts/validate-enums.ts
import { parse } from 'graphql';
import { readFileSync } from 'fs';

function extractEnums(sdlPath: string): Record<string, string[]> {
  const doc = parse(readFileSync(sdlPath, 'utf8'));
  const enums: Record<string, string[]> = {};
  for (const def of doc.definitions) {
    if (def.kind === 'EnumTypeDefinition' && def.values) {
      enums[def.name.value] = def.values.map((v) => v.name.value);
    }
  }
  return enums;
}

function validateParity(pathA: string, pathB: string): void {
  const a = extractEnums(pathA);
  const b = extractEnums(pathB);
  for (const [name, values] of Object.entries(a)) {
    if (!b[name]) continue; // enum not shared with B — skip
    const missing = values.filter((v) => !b[name].includes(v));
    const extra = b[name].filter((v) => !values.includes(v));
    if (missing.length || extra.length) {
      console.error(`Enum drift in "${name}": missing ${JSON.stringify(missing)}, extra ${JSON.stringify(extra)}`);
      process.exit(1);
    }
  }
  console.log('Enum parity check passed.');
}

validateParity('./services/orders/schema.graphql', './services/fulfillment/schema.graphql');

Wire it after the per-subgraph check so both code and registry are validated. The same gate is described from the composition side in managing shared enums across subgraphs.

Two practices make this gate trustworthy rather than theatrical. First, run the parity script against a canonical reference — the published registry SDL or the shared package’s exported values — not just one subgraph against another, so a value that drifted in both subgraphs identically is still caught against the source of truth. Second, surface the failure with the offending values inline, as the script above does; a generic “enum drift detected” forces the engineer to go diffing, whereas naming the missing and extra values turns the fix into a copy-paste. When you adopt registry-driven code generation later, this script becomes a redundant safety net rather than the primary mechanism, but keeping it costs nothing and catches the case where generation silently failed.

For a polyrepo where the subgraph SDLs are not all present in one checkout, pull the reference with rover subgraph fetch (or rover graph introspect) in the CI step and compare the local SDL against the fetched canonical version, so the gate works without vendoring every other team’s schema.

Verification Steps

Confirm the configuration end to end:

  1. Subgraph check — validate against the published graph:
    rover subgraph check my-graph@current \
      --name orders \
      --schema ./services/orders/schema.graphql
  2. Parity script — run the local gate; expect Enum parity check passed. on aligned SDLs and a non-zero exit on drift.
  3. Query the aligned graph and confirm the value serialises:
    query GetOrderStatus {
      order(id: "ORD-123") {
        id
        status
      }
    }
    Expected:
    { "data": { "order": { "id": "ORD-123", "status": "PROCESSING" } } }
  4. Negative check — point a resolver at an undeclared value and confirm the serialisation error so your guard is exercised:
    {
      "errors": [
        { "message": "Enum \"OrderStatus\" cannot represent value: \"PENDING_V2\"", "path": ["order", "status"] }
      ],
      "data": null
    }

Common Mistakes & Gotchas

  • Reaching for @shareable. Enums never take @shareable; it applies only to object fields. Matching value sets are the entire mechanism. If you find yourself adding it to an enum, the real fix is aligning the values.
  • Removing a value to “clean up”. Deletion is a breaking change the moment any client or resolver still emits the value. Deprecate first with @deprecated(reason: "…"), keep the value until telemetry shows zero references, and follow the full procedure in handling enum value deprecation across subgraphs.
  • Trusting the data layer’s casing. A column holding Pending or pending will pass type-checks in your service but fail at GraphQL serialisation. Normalise at the resolver boundary, as in the walkthrough, and the failure becomes a clear local error instead of a cryptic router-side one. This matters most when the enum participates in field resolution via @external and @requires, where a bad value silently resolves as null.

Frequently Asked Questions

How does Federation handle conflicting enum definitions across subgraphs?

For an enum used in both input and output positions, Federation v2 requires identical value sets and composition fails on any difference. Output-only enums compose as the union of values and input-only enums as the intersection, but because status enums usually appear in both positions, design for exact parity and enforce it with the CI gate shown above.

Should enums live in a shared package or be defined per subgraph?

A shared package gives the strongest consistency at the cost of coupling deploys; per-subgraph declarations with a CI parity check preserve autonomy at the cost of needing the gate. Pick by repository layout — package for a monorepo, CI-validated declarations (or registry-driven code generation) for polyrepo — and version the canonical source strictly either way.

What is the safest order to roll out a new enum value?

Add it to the canonical source, deploy the producing subgraphs (output-position additions compose as a union, so they are safe to ship incrementally), then update clients, and only afterwards rely on it in input positions. Removals run in the reverse, deprecation-first order.