Managing Shared Enums Across Subgraphs
In a federated GraphQL architecture, keeping enum definitions consistent across independently deployed services is a deceptively hard engineering problem that surfaces at composition time rather than at runtime. Unlike object types — which Federation can merge field-by-field — enums declared in more than one subgraph must satisfy strict value-set rules during supergraph composition, and a single divergent value can break an otherwise green deployment.
This guide sits within Subgraph Implementation & Entity Resolution and focuses on the full lifecycle of a shared enum: how Federation v2 composes it, how to distribute the canonical value set without coupling every team to one repository, how enums interact with query planning and caching, and how to evolve the value set without a coordinated outage. Where the entity-modelling pages establish boundaries and field ownership, enum management adds a synchronisation discipline on top of those boundaries. Two focused walkthroughs go deeper than this overview: best practices for shared enums across federated services for the diagnostic and CI workflow, and handling enum value deprecation across subgraphs for safe removal of values.
Problem Statement
A shared enum looks trivial — a fixed list of string-like constants — yet it is the most common source of “it composed yesterday” composition failures in a multi-team graph. The difficulty is structural: an enum that appears in two or more subgraphs is a shared contract, but Federation gives you no @shareable-style escape hatch to paper over differences the way you can for object fields. The composition algorithm treats the enum’s value set as something to be reconciled across every subgraph that declares it, and the reconciliation rules differ depending on whether the enum is used as an output type, an input type, or both.
Teams hit three recurring failure shapes. First, drift: two subgraphs ship slightly different value sets and composition rejects the supergraph. Second, premature removal: a value is deleted from one subgraph while a resolver elsewhere still returns it, producing serialisation errors at runtime even though composition passed. Third, coupling: a team over-corrects by forcing every enum through one shared package, so a one-line enum addition now requires a synchronised redeploy of eight services. The patterns in this guide exist to give you alignment without that coupling.
Prerequisites
Concept Deep-Dive: How Federation Composes Enums
Composition handles an enum differently depending on how it is used. The router inspects every subgraph that declares the enum and applies a merge rule based on whether the enum appears in an output position (a return type or object field), an input position (an argument or input-object field), or both.
- Output-only enums compose with the union of all values. A consumer reading the field could receive any value any subgraph might return, so the supergraph exposes the superset. A subgraph that returns a value it did not declare locally is still rejected at serialisation, so “union at composition” is not a licence to return arbitrary strings.
- Input-only enums compose with the intersection of values. An input value must be accepted by every subgraph that could receive it, so the supergraph only advertises values common to all declarations. A value present in one subgraph but missing in another is dropped from the composed input type.
- Enums used in both positions must have identical value sets across all subgraphs. There is no safe union/intersection that satisfies both directions simultaneously, so composition demands exact parity.
This is why the practical advice is almost always “declare the same value set everywhere”: the moment an enum is used as both input and output — which is extremely common for status-style enums — parity becomes mandatory. The diagram below walks the decision path the composer follows.
The practical upshot: treat the union/intersection rules as the mechanism that explains why composition fails, but design as if exact parity is always required. That keeps the same enum safe to use as a status field today and as a filter argument tomorrow without re-architecting.
A worked example makes the asymmetry concrete. Suppose orders declares OrderStatus { PENDING, PROCESSING, SHIPPED } and exposes it only as Order.status (output). Meanwhile analytics declares OrderStatus { PENDING, PROCESSING } and uses it only as a query argument (input). As long as both are single-position, the supergraph composes: the output side advertises the union { PENDING, PROCESSING, SHIPPED } and the input side advertises the intersection { PENDING, PROCESSING }. The instant analytics also returns the enum from a field — making it dual-position graph-wide — composition flips to demanding identical sets and the missing SHIPPED becomes a hard error. Teams are routinely surprised by this because the breaking change is not the enum edit itself but a seemingly unrelated new field that returns the enum. Designing for parity from the outset removes that whole class of surprise.
One more subtlety: value descriptions and @deprecated annotations do not have to match across subgraphs, only the value names do. Composition merges descriptions (preferring a non-empty one) and propagates the deprecation if any subgraph marks the value deprecated. That means you can stage a deprecation by annotating the value in a single subgraph first, then rolling the annotation out — the supergraph will already advertise the value as deprecated to clients while the value name itself stays present everywhere.
Federation-Compliant SDL Declaration
Every subgraph that references the enum declares it with an identical value set. Enums never take @shareable — that directive applies only to object fields.
# subgraph-inventory/schema.graphql
extend schema
@link(url: "https://specs.apollo.dev/federation/v2.9", import: ["@key"])
enum OrderStatus {
PENDING
PROCESSING
FULFILLED
CANCELLED
}
type Order @key(fields: "id") {
id: ID!
status: OrderStatus!
}
A second subgraph that exposes the same enum as a filter argument must declare the identical set, because the enum is now used in both positions across the graph:
# subgraph-analytics/schema.graphql
extend schema
@link(url: "https://specs.apollo.dev/federation/v2.9", import: ["@key"])
enum OrderStatus {
PENDING
PROCESSING
FULFILLED
CANCELLED
}
type Query {
orderCountByStatus(status: OrderStatus!): Int!
}
If only one subgraph ever declares the enum, no cross-subgraph synchronisation applies — it is a local type and composes trivially.
Directive & Composition Spec Table
| Concept | Syntax | Valid values | Composition-time vs runtime |
|---|---|---|---|
| Enum declaration | enum Status { A B C } |
UPPER_SNAKE_CASE recommended; must match /[_A-Za-z][_0-9A-Za-z]*/ |
Composition-time: value sets reconciled across subgraphs |
| Output-position merge | implicit | union of all subgraph values | Composition-time |
| Input-position merge | implicit | intersection of all subgraph values | Composition-time |
| Both positions | implicit | identical sets required | Composition-time (hard error on mismatch) |
| Deprecate a value | VALUE @deprecated(reason: "…") |
any string reason | Composition-time annotation; runtime keeps resolving the value |
| Hide a value from the supergraph | VALUE @inaccessible |
n/a | Composition-time: removes value from the API schema, keeps it internal |
@shareable on enums |
not applicable | — | N/A — enums never use @shareable |
| Serialisation guard | resolver returns declared value | only values in the composed enum | Runtime: undeclared value throws at the serialisation layer |
@inaccessible is the underused tool here: it lets one subgraph carry an internal-only value that the composed API schema never exposes, which is handy when a single service needs a transitional state that clients should never see.
Step-by-Step Implementation
Step 1 — Define the canonical value set
Pick a single source of truth. For a monorepo, a shared package; for a polyrepo, a small registry file or the published subgraph SDL itself. Either way, write the values down once with a casing convention applied.
// packages/shared-types/src/order-status.ts
export enum OrderStatus {
PENDING = 'PENDING',
PROCESSING = 'PROCESSING',
FULFILLED = 'FULFILLED',
CANCELLED = 'CANCELLED',
}
Step 2 — Wire the enum into a subgraph schema
Use buildSubgraphSchema so the Federation directives are recognised, and keep the SDL value set in lock-step with the TypeScript enum.
// subgraph-inventory/src/schema.ts
import { buildSubgraphSchema } from '@apollo/subgraph';
import gql from 'graphql-tag';
import { OrderStatus } from '@acme/shared-types';
const typeDefs = gql`
extend schema
@link(url: "https://specs.apollo.dev/federation/v2.9", import: ["@key"])
enum OrderStatus {
PENDING
PROCESSING
FULFILLED
CANCELLED
}
type Order @key(fields: "id") {
id: ID!
status: OrderStatus!
}
`;
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; // order.status must be one of OrderStatus
},
},
};
export const schema = buildSubgraphSchema({ typeDefs, resolvers });
Step 3 — Guard the resolver against undeclared values
The most common runtime failure is a resolver returning a database string the enum does not contain. Normalise at the boundary so a stray pending never reaches serialisation.
// subgraph-inventory/src/normalize.ts
import { OrderStatus } from '@acme/shared-types';
const VALID = new Set(Object.values(OrderStatus));
export function toOrderStatus(raw: string): OrderStatus {
const candidate = raw.toUpperCase();
if (!VALID.has(candidate as OrderStatus)) {
throw new Error(`Unknown OrderStatus from data layer: "${raw}"`);
}
return candidate as OrderStatus;
}
Failing fast at the resolver is far easier to debug than a serialisation error surfaced three hops away at the router.
Step 4 — Validate parity before merging
Run a subgraph check against the published graph so divergence is caught on the pull request, not at deploy.
# Validate this subgraph's SDL against the registry's composed schema
rover subgraph check my-graph@current \
--name inventory \
--schema ./subgraph-inventory/schema.graphql
Step 5 — Compose locally to confirm the supergraph builds
rover supergraph compose --config supergraph.yaml > supergraph.graphql
A non-zero exit with incompatible values means an enum used in both positions has drifted; align the SDLs before committing.
Composition Pipeline Integration
Enum parity belongs in the same pipeline that already runs your schema checks. The pattern: check every changed subgraph, then compose the full supergraph as a gate before publish.
# .github/workflows/schema.yml
name: schema-check
on: pull_request
jobs:
enum-parity:
runs-on: ubuntu-latest
env:
APOLLO_KEY: ${{ secrets.APOLLO_KEY }}
steps:
- uses: actions/checkout@v4
- name: Install Rover
run: curl -sSL https://rover.apollo.dev/nix/latest | sh
- name: Check changed subgraph
run: |
rover subgraph check my-graph@current \
--name inventory \
--schema ./subgraph-inventory/schema.graphql
- name: Compose supergraph as a gate
run: rover supergraph compose --config supergraph.yaml > /dev/null
This mirrors the broader approach described in schema validation in CI/CD pipelines; enum parity is simply one class of check that pipeline enforces. When the enum lives in a shared package, also add a step that fails if the SDL value set and the package’s exported values diverge — the worst drift is between code and schema inside a single service.
Performance & Scale Considerations
Enums are cheap at runtime, but the choices around them have real performance and operational consequences as the graph grows.
- Query planning. Enums do not add resolver hops, so they are query-plan-neutral on their own. The cost shows up indirectly: when an enum is a
@keycomponent or appears in a@requiresselection, drift can force the planner down a fallback path. If your enum participates in field resolution, read using @external and @requires for field resolution to understand how a mismatched value can cause a@requiresfield to resolve asnull. - Response caching. Divergent enum representations fragment caches. If one subgraph emits
SHIPPEDand another emits a transitionalIN_TRANSITfor the same logical state, the router’s entity cache stores both, halving hit rates for what is conceptually one value. Canonicalise before caching. - N+1 and batching. Enums themselves don’t create N+1, but status fields are common batch keys. When you batch entity resolution, keep the enum normalisation inside the loader so the cache key is stable — see batching entity resolution with DataLoader.
- Scale threshold. Below five subgraphs, manual coordination of enum changes is tractable. Past that, the deployment-ordering cost of adding a value grows superlinearly, and a registry-driven code-generation approach pays for itself. The best practices guide covers that automation in depth.
Evolution & Distribution Patterns
Three distribution patterns dominate production graphs, each with a different coupling/safety trade-off:
- Monorepo shared package. Export the enum once and import it everywhere for compile-time type safety. Strongest guarantees, tightest coupling — a package bump can force a fleet redeploy. Pin versions strictly and require explicit dependency bumps in pull requests.
- Independent declarations with CI validation. Each service owns its SDL; a CI gate enforces parity. Preserves autonomy at the cost of needing disciplined contract tests. Best for polyrepo organisations.
- Code generation from a central registry. A single canonical definition generates SDL and per-language types via CI. Combines a single source of truth with per-service builds — the sweet spot once the graph is large.
Whichever you choose, adding a value to an output-position enum is the safe direction (it composes as a union); removing a value is the dangerous one and must go through deprecation. The full removal workflow lives in handling enum value deprecation across subgraphs.
In practice the choice between these patterns is rarely permanent — graphs migrate along the list as they grow. A two-team graph starts with independent declarations because the coordination cost is a single Slack message. As the team count climbs, the same change starts touching half a dozen repositories, and the failure rate of “did everyone update their SDL?” rises with it; that is the signal to move to a shared package or, better, registry-driven generation. The mistake to avoid is jumping straight to a heavyweight shared package on a small graph, where the coupling tax outweighs the parity benefit, or staying on manual coordination long after the graph has outgrown it. Treat the distribution pattern as a dial you turn as the number of subgraphs declaring shared enums crosses roughly five, not a one-time architectural decision.
It is also worth distinguishing value changes from type changes. Renaming the enum type, changing a field’s enum type, or moving the enum to a different subgraph are all schema-shape changes that the entity-modelling rules govern; the value-set rules in this guide apply specifically to the list of constants inside a single, stable enum type. Keeping the two concerns separate in code review — one reviewer pass for shape, one for value parity — catches the largest share of enum incidents before they reach composition.
Deployment Coordination Workflow:
- Add the value to the canonical registry or shared package.
- Deploy all subgraphs that declare the enum (parallelisable for output-only enums; ordered for input-position enums).
- Update clients to handle the new state.
- Remove deprecated values only after telemetry confirms zero active references.
Failure Modes & Debugging
| Symptom | Exact message (abridged) | Root cause | Resolution |
|---|---|---|---|
| Composition fails | Enum type "OrderStatus" has incompatible values across subgraphs |
An enum used in both positions has divergent value sets | Align the SDL value sets; deploy together or use phased deprecation |
| Runtime serialisation error | Enum "OrderStatus" cannot represent value: "PENDING_V2" |
A resolver returns a value not in the composed enum | Normalise at the resolver boundary (Step 3); add the value to all subgraphs first |
@requires field is null |
no error, silent null | The router cannot map an incoming enum value to the expected type | Confirm the value exists in every subgraph in the resolution path |
| Cache hit rate drops | none — observed in metrics | Two subgraphs emit different strings for one logical state | Canonicalise enum values before they reach the router’s entity cache |
| Breaking change on deploy | UnknownEnumValue for clients |
A value was removed before clients migrated | Re-add the value with @deprecated; run a phased rollout |
Frequently Asked Questions
Can subgraphs define different enum values for the same type?
It depends on usage. For an enum used purely as an output type, composition takes the union of all subgraph values; for input-only it takes the intersection; for an enum used in both positions the value sets must be identical or composition fails. Because most status-style enums end up in both positions, the safe rule is to declare identical values everywhere and enforce it in CI.
Do enums need the @shareable directive in Federation v2?
No. @shareable applies only to object type fields that more than one subgraph can resolve. Enums are reconciled by their value sets directly, so they are never marked @shareable — declaring them with matching values is sufficient.
How do I safely deprecate an enum value in a federated graph?
Mark the value with @deprecated(reason: "…"), keep it in every subgraph’s SDL until telemetry shows no active references, optionally add a resolver fallback that maps it to a successor, and remove it only during a coordinated composition. The end-to-end procedure is documented in handling enum value deprecation across subgraphs.
Does sharing enums through a monorepo package hurt subgraph independence?
Yes — it couples deploys, because a package change can force every dependent service to redeploy. Mitigate it with strict semantic versioning and explicit dependency bumps, or move to a registry-driven code-generation model that keeps a single source of truth while letting each service build independently.
What happens if an enum is only used in one subgraph?
Then it is a local type with no synchronisation requirement and composes trivially. It only becomes a shared contract once a second subgraph declares the same enum or it surfaces in the composed supergraph schema, at which point the value-set rules apply.