Migrating Field Ownership with the @override Directive

Moving a field from one subgraph to another sounds like a two-step change and is not: delete it from one side and add it to the other, and there is a window where the field exists nowhere and every client selecting it breaks. @override removes that window entirely by letting both subgraphs declare the field while exactly one of them resolves it. This page covers the full migration, including the progressive variant that shifts traffic gradually. It sits under Type Ownership and Shared Schema Contracts.

When to use this pattern

  • Use it whenever a field moves between subgraphs, which happens as a monolith is decomposed and as domains are re-drawn.
  • Use the progressive form when the new implementation is unproven and you want a percentage of traffic to validate it against the old one.
  • Skip it when the field is being genuinely removed rather than moved. That is a deprecation, covered in deprecating fields safely in a federated graph.

Prerequisites

Three states, no gap

@override(from: "old-subgraph") on a field declaration says: I am taking over this field; route it to me instead of to that subgraph. Composition accepts both declarations simultaneously, which is what removes the outage window.

The migration therefore has three states and every one of them is fully serving.

Before. The old subgraph declares and resolves the field. The new subgraph does not mention it.

Overlapping. Both declare it. The new one carries @override(from: "old"), so the router sends the field there. The old implementation still exists, still works, and is one publish away from being used again.

After. The old declaration is deleted. The new subgraph is the sole owner and the @override argument becomes vestigial — it can be removed at leisure.

The property that matters is that the field is resolvable in all three, so no client release has to be coordinated with the move and a rollback is a publish rather than a deploy.

The field never stops existing Before the migration the old subgraph resolves the field. During the overlap both declare it and the new one resolves it. After, the old declaration is removed. Clients see no interruption at any point. Order.total moves from monolith to billing before monolith: declares, resolves billing: absent resolvable ✓ overlapping monolith: declares, unused billing: @override(from:) resolvable ✓ — and revertible after monolith: deleted billing: sole owner resolvable ✓ Every arrow is a schema publish, not a deploy — which is why each step takes seconds and reverses in seconds. The code on both sides is already running throughout. Leaving the middle state in place indefinitely is the only real hazard.

Implementation walkthrough

Deploy the new implementation first, publish the override second. Reversing that order routes traffic to code that is not running.

# billing subgraph — the new owner
extend schema
  @link(url: "https://specs.apollo.dev/federation/v2.9",
        import: ["@key", "@override"])

type Order @key(fields: "id") {
  id: ID!
  # The `from` argument names the subgraph as registered, not its URL or repo.
  total: Money! @override(from: "monolith")
}

The old subgraph changes nothing at this point. Its declaration stays, its resolver stays, and it simply stops receiving requests for the field. That is what makes the state revertible: removing the @override and republishing sends traffic straight back.

For an unproven implementation, the progressive form shifts a percentage instead of everything:

type Order @key(fields: "id") {
  id: ID!
  # 5% to billing, 95% still to the monolith. The label is a router-side
  # rule name, so the split changes with a publish rather than a deploy.
  total: Money! @override(from: "monolith", label: "percent(5)")
}

Raise the percentage as confidence grows — five, then twenty-five, then fifty, then a hundred — publishing each time. Because the split is expressed in the schema, the whole ramp needs no deploys and no coordination with either team’s release cycle.

Only once the field has served all traffic from the new owner for a full release cycle should the old declaration go:

# monolith subgraph — final step, after the traffic has moved
type Order @key(fields: "id") {
  id: ID!
  # total: Money!   ← removed
}

Verification steps

Confirm the overlap composes before publishing anything:

rover subgraph check my-graph@current --name billing --schema ./billing/schema.graphql

Then confirm the routing actually moved, which is a query-plan question rather than a data question. Two implementations returning the same value look identical in a response, so the only reliable check is which subgraph the plan names:

APOLLO_ROUTER_LOG=debug,apollo_router::query_planner=trace ./router \
  --supergraph supergraph.graphql --config router.yaml
# then run an operation selecting Order.total and read the Fetch node's service

During a progressive override, compare the two implementations rather than only watching for errors. Sample the field from both sides for the same order and assert they agree — a silently different value is the failure mode this ramp exists to catch, and it will not show up as an error rate.

Finally, before deleting the old declaration, confirm the traffic split reads as one hundred percent for a full cycle of your slowest deploy. Removing it while a fraction still routes to the monolith turns a migration into an incident.

Each step is a publish, and each is reversible Traffic shifts from the old subgraph to the new one in stages of five, twenty-five, fifty and one hundred percent, each set by publishing a new label, and only then is the old declaration removed. Share of Order.total served by billing percent(5) — compare values, watch errors percent(25) percent(50) 100% — hold here for a full release cycle before deleting anything 0% 100% Reverting any row is one publish. Deleting the old declaration is the first irreversible step.

There is a wider point worth drawing out, because it changes how these migrations feel to run. Everything in this sequence is a schema publish rather than a deploy. Both implementations are already running the whole time; what moves is the router’s belief about which one to ask. That has three practical consequences.

Each step takes seconds rather than a release cycle, so the ramp can be driven by observed behaviour rather than by a schedule. Each step reverses in seconds too, which means the cost of trying a percentage and disliking what you see is close to zero. And because no deploy is involved, the migration is not blocked by either team’s release train — a team that ships fortnightly can still move a field on a Tuesday afternoon.

The corollary is that the risky moment is not any of the ramp steps; it is the deletion at the end, which is the first change that cannot be undone by a publish. Treat that step as its own decision, made after the traffic has been at one hundred percent long enough for a rollback of either service to be safe, rather than as bookkeeping tacked onto the last percentage increase.

A migration this cheap invites doing several at once, so it helps to know which steps are safe to overlap across fields and which must be serialised.

What to batch and what to serialise Deploys and publishes batch safely, ramps should be attributable, and deletions belong one at a time. What can run in parallel action parallel across fields? why deploying new implementations yes independent code paths publishing overrides yes one publish can carry several ramping percentages cautiously a shared cause is hard to attribute deleting old declarations one at a time the only irreversible step Ramping several fields together makes a regression ambiguous, which costs more than the time it saved. Deletions are cheap individually and expensive to untangle in a batch — space them out.

Common mistakes & gotchas

Naming the wrong subgraph in from. It takes the registered subgraph name, not a URL, a service name, or a repository. A mismatch composes in some versions and simply does not move the field, which reads as “the override did nothing”.

Publishing the override before the new code is deployed. Traffic routes to an implementation that is not running. Deploy first, publish second — always in that order.

Leaving the overlapping state indefinitely. Two implementations of one field drift, and eventually a rollback restores the older one with different behaviour. Put a date on removing the old declaration when you open the override.

Frequently Asked Questions

Can a whole type be overridden at once?

No — @override is per field, deliberately. Moving an entity wholesale means moving its key, which is a much larger change than moving fields. In practice a type migration is a sequence of field migrations followed by a change of which subgraph resolves the key, and doing it field by field keeps every intermediate state serving.

What happens to @requires on an overridden field?

It moves with the field and is evaluated in the new owner’s subgraph, which means the new owner needs the required fields to be reachable from its position in the graph. This is the most common reason an override composes in isolation and fails in the real graph, and it is worth checking before the ramp rather than during it.

Does progressive override guarantee consistency for one user?

No. The split is per request, so the same user can see the old implementation on one request and the new one on the next. If the two implementations can disagree, that visible flapping is a reason to fix the disagreement rather than to abandon the ramp — but it does mean a progressive override is unsuitable for a field where consistency within a session matters.

Can I override a field that another subgraph also overrides?

No, and composition rejects it. A field has exactly one override in flight, which keeps the question of who resolves it unambiguous. If two teams both want to take a field, that is an ownership conversation rather than a directive problem.