I recently migrated a system from a middleware API gateway that remapped GraphQL queries onto REST endpoints exposed by microservices, to an architecture where the services expose GraphQL directly via federation. During the migration, the new router treated the legacy middleware itself as a federated subgraph, which made it possible to override queries and resolve entities gradually without breaking changes for the frontend.
The previous middleware gateway
The original gateway exposed a single GraphQL schema and translated each query into one or more REST calls to downstream services. In practice this meant the gateway owned almost all of the schema: types, queries, and how they mapped to microservice REST endpoints.
Because of this design, the gateway became a bottleneck: any new feature required schema changes, mapping logic, and often ad‑hoc orchestration code in the gateway before a microservice could be used by the frontend. Over time, duplication in data loading logic appeared between the gateway and services that already had the necessary data.
From a GraphQL standpoint, the gateway was a single monolithic graph. The microservices knew nothing about the GraphQL schema, and only exposed REST endpoints.
A gateway that belonged to nobody
One practical problem with the legacy middleware was ownership. The gateway was shared across different teams and domains, but it didn’t clearly belong to any single team.
Because of this, nobody really “owned” the service: there was no product owner for the gateway itself, and no team whose main responsibility was to evolve its schema and routing logic. Changes happened as patches for specific features, often driven by whichever team needed something urgently.
Over time, this lack of clear ownership turned the gateway into a shared dependency that everyone used but nobody wanted to maintain. It carried business‑critical traffic, but governance, deprecation strategy, and schema evolution were largely improvised around it instead of being part of a well‑defined process.
The new federated architecture
The target architecture was a federated graph where each microservice exposes its own GraphQL subgraph with a router composing these subgraphs into a single supergraph schema that clients query. This design matches how modern federation systems work: each subgraph defines part of the schema, the router composes them into a unified supergraph, and clients talk only to the router.
In a federated graph, shared types become entities, identified by one or more key fields annotated with a directive like @key. An entity can be defined in one subgraph and extended in others, with the router resolving references between subgraphs using the entity’s key.
The goal of the migration was to move ownership of entities and queries from the legacy gateway to the actual services, while keeping the public schema stable for the frontend and finally giving each piece of the graph a clear owning team.
For Kotlin services built on Spring Boot we used the Netflix DGS Framework to implement federated subgraphs, while for Python services we evaluated Strawberry GraphQL as a federation-capable option, although we never went as far as implementing it in production.
Treating the legacy gateway as a subgraph
To bridge between the old and new worlds, I exposed the legacy gateway as one more subgraph behind the new router. From the router’s perspective, there was now:
- A legacy subgraph: the previous gateway’s GraphQL schema.
- One or more new subgraphs: individual microservices exposing GraphQL directly.
- A router: composing all these schemas into a federated supergraph.
With this setup, the router still resolved most queries against the legacy subgraph initially. As new services rolled out GraphQL subgraphs, I could start shifting ownership of types and fields to them, while the router continued to expose a single, backward‑compatible schema.
How entity resolution works in federation
Before explaining how I moved entities between graphs, it helps to recap how federation resolves entities.
In a federated graph:
- An entity type is declared with a key, for example
type User @key(fields: "id") { id: ID! ... }. The key fields uniquely identify the entity. - When a query needs fields from multiple subgraphs, the router issues an
_entitiesquery to the owning subgraph, sending representation objects containing__typenameand the key fields to resolve the entity. - The owning subgraph implements a reference resolver that turns the representation into the actual entity instance.
A simplified example of an entity and resolver in pseudo‑code:
# Subgraph A (owns User)
type User @key(fields: "id") {
id: ID!
name: String!
}
# Resolver in Subgraph A
User __resolveReference(reference) {
return userRepository.findById(reference.id)
}
When another subgraph extends User with extra fields, the router still calls _entities on the owning subgraph to resolve the base entity, then uses that to fetch the extended fields.
This mechanism (keys, representations, and reference resolvers) is exactly what I used to move entities from the legacy subgraph to the new service subgraphs.
Resolving entities from the legacy graph to new subgraphs
The first step was to treat types owned by the legacy gateway as entities, even though the legacy schema was not originally designed that way. For important shared types like User, Order, or Product, I introduced @key annotations in the legacy subgraph schema so the router could treat them as entities.
Then, for each type I wanted to migrate, I followed a pattern similar to what federation documentation describes as a safe way to move an entity between subgraphs:
- Duplicate the entity definition in the new subgraph with the same key fields and shape as in the legacy subgraph. Composition is strict here: any non‑key field defined in both subgraphs has to be marked
@shareablein every subgraph that defines it, or the publish is rejected. Apollo states the rule directly in sharing types: a field marked@shareablein one subgraph must be@shareableor@externalin all the others. This is a hard composition error, not a transitional warning you can postpone. - Implement an entity resolver in the new subgraph, so it can resolve the entity from a representation object (key fields). This usually means adding a
__resolveReferenceor equivalent resolver that loads the entity from the new service’s storage. - Verify behavior match: the new resolver must return the same data as the legacy graph for the fields that are already used by clients. Federation guidelines emphasize that multiple subgraphs resolving the same field must behave identically.
- Remove the entity definition from the legacy subgraph once the new subgraph is ready. After this, the router resolves the entity exclusively from the new subgraph.
Conceptually, the flow looked like this:
- Initially:
Useris an entity defined in the legacy subgraph; all queries and mutations go through it. - Transitional:Â
User is also defined in the new subgraph, and both have reference resolvers. There is no setting that makes the router “prefer” one of them (forÂ@shareable fields the query planner picks by its own cost heuristics) so deterministic routing during validation has to come from the@overridedirective. - Final:
Useris defined only in the new subgraph; the router routes_entitiesqueries there, and the legacy subgraph no longer ownsUser.
Because the router still exposes the same User type in the supergraph, the frontend doesn’t see a breaking change; only the underlying owner and resolver change.
Extending entities in the new subgraphs
In some cases I didn’t move ownership immediately, but started by contributing fields to entities owned by the legacy graph. This is the standard federation pattern, shown here in Federation 2 syntax:
# Legacy subgraph
type Product @key(fields: "id") {
id: ID!
name: String!
price: Float!
}
# New Inventory subgraph (Federation 2)
type Product @key(fields: "id") {
id: ID!
inStock: Boolean!
}
Here:
- The legacy subgraph remains the canonical owner of
Productand fields likenameandprice. - The new subgraph contributes an
inStockfield toProductand repeats the same key. In Federation 2 this needs neitherextendnor@externalon the key field; both were Federation 1 requirements, and dropping them is one of the syntax changes in the v2 migration.
The router’s execution plan becomes:
- Fetch
Productentities from the legacy subgraph. - Use their
idas the key to resolveinStockfrom the new inventory subgraph.
This approach is useful when new services need to augment existing types before completely owning them. Once the new subgraph is stable, I could follow the ownership migration pattern described earlier to move Product entirely into the new service.
The N+1 problem when resolving entities by reference
Moving entities into the new subgraphs made one performance problem much easier to hit: the classic N+1, this time at the federation layer. It showed up as soon as a query returned a list of entities whose fields lived in more than one subgraph.
The router itself is not the culprit. When a query returns 100 Order objects and needs Order.customer from the Users subgraph, the router sends one _entities request carrying 100 representations, not 100 requests. That hop is already batched. The problem is what the subgraph does with those 100 representations once they arrive.
# Users subgraph — naive reference resolver
User __resolveReference(reference) {
return userRepository.findById(reference.id) # one call per representation
}
With a resolver like this, the runtime invokes the function once per representation, so a single _entities request turns into 100 database queries or 100 REST calls downstream. The router batched the network hop and the subgraph promptly un‑batched it. During the migration this was worst in the legacy subgraph, which resolved every reference through the same per‑id REST endpoints the old gateway had always called.
Batching with a DataLoader inside the reference resolver
The standard answer is the DataLoader pattern: collect the keys requested during a single execution tick, issue one batched call, then hand each caller back its own result. The reference implementation is graphql/dataloader and Apollo documents batching and caching as the direct answer to N+1 in its data fetching guide.
# Users subgraph — batched reference resolver
userLoader = DataLoader(keys -> userRepository.findAllByIds(keys)) # one call for all keys
User __resolveReference(reference) {
return userLoader.load(reference.id) # resolved in a single batch
}
Neither the schema nor the resolver signature changes. The only difference is that findAllByIds is called once with 100 ids instead of findById being called 100 times. On the Kotlin services this meant a @DgsDataLoader with a batch loader wired into the entity fetcher; DGS documents the pattern under Data Loaders (N+1), and underneath it is java-dataloader. Strawberry ships an equivalent DataLoader for the Python side.
Two details caused real bugs before I got this right:
- The loader cache is meant to be per request. A loader shared across requests will serve stale entities and grow unbounded, so it has to be instantiated per execution context and the framework has to be configured to do that.
- The batch function must return results in the same order as the keys it received, padding misses with null. Get this wrong and entities are silently attached to the wrong parent, which composition checks cannot catch for you.
Designing the schema so the hop never happens
The cheapest _entities call is the one the router never plans. Two schema‑level levers cut round trips before any loader is involved:
@provideslets a subgraph declare that it can already return certain fields of an entity it does not own, so the router skips the extra hop for that path entirely. Apollo covers this in contributing and referencing entity fields.- Key granularity matters. If a field the client always requests is derivable from the key itself, keeping it on the owning side avoids a jump made solely to resolve one attribute.
- For a small, stable field, denormalising it into the calling subgraph is sometimes cheaper than any amount of batching. Not every nice‑to‑have field deserves a network hop.
I treated batching as the default everywhere and kept these schema changes for the few high‑traffic paths where the extra subgraph hop was clearly visible in traces.
Measuring it
The number I watched was the ratio between _entities calls and downstream calls (SQL queries or REST requests) within the same trace. One _entities request producing one downstream batch is healthy; one producing N is the bug. Distributed tracing through the router made the difference obvious without any guesswork.
One thing worth separating: router-level query batching is a different feature. It merges multiple client operations into a single HTTP request and does nothing about N+1 inside a subgraph. Both are called batching and confusing them sent me looking for the fix in the wrong layer more than once.
Overriding queries without breaking the frontend
Entities are only half of the story. The other part of the migration was overriding root queries so they are served by the new subgraphs instead of the legacy gateway, while keeping the query names and shapes unchanged for the frontend.
Federation v2 adds an @override directive that lets one subgraph take over a field defined in another. It applies to root fields as well (Query is just another object type) and Apollo documents this as the supported way to migrate a field between subgraphs, so it is not a trick you have to justify.
A simplified example:
# Legacy subgraph
type Query {
ordersByUser(userId: ID!): [Order!]!
}
# New Orders subgraph
type Query {
ordersByUser(userId: ID!): [Order!]! @override(from: "legacy")
}
In this setup:
- Both subgraphs define a
ordersByUserquery with the same arguments and return type. - The
@override(from: "legacy")directive tells the router that the new subgraph’s implementation should take precedence over the legacy one. - During composition, the router validates that the overriding field is compatible and then routes calls to the new subgraph.
This approach keeps the schema stable: the frontend continues calling ordersByUser, but the router now executes the query in the new service.
Where @override was not available or not desirable, another practical option was to deprecate the legacy query and introduce a new query with the same behavior in the new subgraph, then gradually migrate clients. However, this requires frontend changes and doesn’t meet the “no breaking change” goal, so I primarily leaned on schema‑level overrides at the router.
Ramping traffic over with progressive @override
For the most sensitive queries, flipping every request to a new subgraph in a single composition felt too coarse. I wanted a percentage ramp, and it is worth being precise about what the router can and cannot do here.
A federation router does not fail over between subgraphs. The query plan is derived from the composed supergraph before execution, so every field has exactly one resolution path within a given plan. Timeouts and retries target the same subgraph that was planned; there is no “if the new service is down, ask the legacy one” setting to enable. Anything resembling a gradual cutover has to be expressed in the schema, not in router configuration.
Federation v2 does exactly that with progressive @override: a label argument on the directive that assigns a share of traffic to the overriding subgraph.
# New Orders subgraph — 5% of traffic here, the rest still served by legacy
type Query {
ordersByUser(userId: ID!): [Order!]! @override(from: "legacy", label: "percent(5)")
}
The pattern I followed for sensitive queries was:
- Implement the query in the new subgraph with identical semantics.
- Publish it with a small percentage label, then compare responses, latency, and error rates between the two implementations in tracing.
- Raise the percentage in steps. If the new subgraph misbehaves, lower it again: the legacy implementation is still composed and serving the remainder, so backing out is a schema publish rather than an incident.
- Finish at
percent(100), remove the label, and only then delete the field from the legacy subgraph.
The label can also be delegated to a feature flag service instead of a fixed percentage, which is how you would tie the ramp to a rollout tool you already run. Apollo documents both variants under migrating entities and fields.
The property that mattered to me was that the safety net is a composition decision I control and can reverse in one publish, not a runtime behavior I am hoping the router provides. Progressive @override is a Federation v2 feature and depends on router support, so it is worth confirming availability on your own router before designing a rollout around it.
Using a schema registry for gradual schema updates
To keep this migration under control, I didn’t rely only on router configuration files. I used a schema registry to version the federated schemas and to validate changes before they reached the router.
A schema registry acts as the source of truth for a federated graph’s schema: subgraphs publish their SDL, the registry composes those subgraphs into a supergraph, and the router consumes that composed schema. Registries like Apollo GraphOS, GraphQL Hive, and WunderGraph Cosmo provide history, breaking‑change checks, and composition validation out of the box.
In my case, I treated the registry as a way to version gradual schema changes:
- Each subgraph published its schema variant when ready, instead of pushing directly to the router.
- The registry composed a candidate supergraph and ran composition checks; if composition failed, the publish was rejected and the current supergraph stayed live.
- I manually updated the router to use the new supergraph only after reviewing that the change set did not introduce breaking changes.
This manual step was important in the migration phase. I could update several subgraphs at the same time (for example, moving an entity and overriding a query in one batch) but still inspect the composed schema before the router started serving it.
The result was a controlled rollout process:
- Subgraphs evolve their schemas independently.
- The registry composes and validates the combined schema.
- I switch the router to the new supergraph when I am satisfied with the checks.
This approach gave me confidence that we weren’t introducing accidental breaking changes while moving ownership from the legacy gateway to the new services, and it replaced the previous informal “someone edits the gateway schema” workflow with a repeatable schema evolution pipeline.
Coordinating entity and query migration
One practical challenge was coordinating the migration of entities and queries together. For example, moving ordersByUser to the Orders service required:
- The Orders subgraph to implement
Orderas an entity with a key, plus reference resolvers. - The Orders subgraph to expose
ordersByUseras a root query, either overriding or replacing the legacy implementation. - Other subgraphs (like Payments or Shipping) to extend
Orderif they needed additional fields, using@external,@requires, or@provideswhere appropriate.
Federation directives like @requires, @provides, and @shareable helped keep cross‑subgraph relationships explicit. For example:
@requiresspecifies that a subgraph needs certain fields from another subgraph to compute its own fields.@providesindicates that a subgraph can supply additional fields of a related entity it does not own, avoiding extra hops.@shareableallows multiple subgraphs to resolve the same field, which is useful when duplicating behavior for migration.
Using these directives together with the schema registry, I could design the migration so that the router had clear rules for where each field came from, how entities should be resolved, and which supergraph version was safe to serve.
Trade-offs and edge cases
This migration pattern has some trade‑offs:
- Temporary duplication: for a while, entities and queries exist in both the legacy and new subgraphs.
- Strict schema compatibility: federation composition is strict about conflicting definitions, incompatible field types, or missing keys. Tools validate that each field is resolvable and that entity resolution paths exist, which is helpful but also means you can’t take shortcuts.
- Operational complexity: running both the legacy gateway and new subgraphs behind a router adds operational overhead: more services to monitor, more moving parts, and more potential failure modes.
- Governance: moving from a shared gateway that belongs to nobody to a federated graph means you need clear ownership per subgraph and a central registry to track which version is actually live in production.
Despite these trade‑offs, the ability to treat the legacy gateway as a federated subgraph, use a schema registry for gradual supergraph versioning, and override queries and entities step by step is what made the migration feasible in production.
The final result
After completing the migration, the system no longer relies on a middleware gateway that hides REST endpoints behind a single GraphQL schema. Each microservice now exposes a GraphQL subgraph, the router composes them into a federated supergraph, and entities are resolved directly from the services that own their data.
The frontend still talks to one GraphQL endpoint with the same queries and types, but behind that endpoint:
- Ownership of entities has moved from the legacy graph to the actual services using federation’s entity resolution patterns.
- Queries have been overridden so they execute in the new subgraphs, with federation v2 features like
@overrideused where available. - A schema registry versions every supergraph, validates composition, and lets me manually choose when the router adopts a new schema, which made it possible to update multiple subgraphs in sync without surprises.
- The legacy gateway has been reduced to a transitional subgraph and can eventually be retired once all entities and queries have been migrated.
For me, the key benefit of using federation in this way was that I could treat the migration as a series of controlled, reversible steps instead of a big bang rewrite. The router and the schema registry became the central place to manage ownership changes, and entity resolution gave me a predictable mechanism to move data and behavior from one graph to another without breaking the client contract.