Apollo Federation v2 and Netflix DGS
What it is
Federation is a way to build one GraphQL schema out of many independently deployed services. Clients query a single endpoint; behind it, a router (or gateway) plans the query, calls the subgraphs that own the requested fields, and assembles the response.
The property that makes it federation rather than proxying: a single type can be
owned by several subgraphs. A Product has name and price from the catalogue
service, inventory from the warehouse service, and reviews from the reviews
service, and the client sees one type. Each subgraph declares which fields it
contributes and which key identifies the entity, and the router works out the join.
Apollo Federation is the specification (v1 in 2019, v2 in 2022, which changed enough to matter). Netflix DGS is a Java framework for building federated subgraphs on Spring Boot; it is not an alternative to Federation, it is an implementation of the subgraph side of it. That pairing (DGS subgraphs behind an Apollo Router) is the standard JVM deployment, and confusing DGS with a competing federation spec is the most common misunderstanding here.
What it is confused with: schema stitching, the older approach where a gateway holds configuration describing how to link schemas together. Stitching puts the join logic in the gateway, so every schema change is a gateway change. Federation inverts this: the subgraphs declare their own contributions and the router derives the plan, so a team can add a field without touching anyone else's deployment. That inversion is the entire point and it is an organisational property before it is a technical one.
The problem it solves
A single GraphQL schema across a company hits an ownership wall. Two failure modes, and teams usually try both before federating.
One monolithic GraphQL service. Every team commits resolvers to one repository. Deploys serialise: a reviews change waits behind a catalogue change. The service takes on every downstream dependency, so its blast radius is the union of everyone's. Nobody owns it, so its on-call rotation is either a dedicated team that understands none of the domains or a shared rotation nobody staffs.
One GraphQL endpoint per service. Now the client queries three endpoints and joins the results itself, which is what GraphQL was supposed to eliminate. Mobile clients pay three round trips on a high-latency connection, and the join logic ships in an app binary you cannot update.
Federation gives you the client experience of the first and the ownership model of the second. The unit of deployment is a subgraph and the unit of consumption is the supergraph, and those being different is the whole value proposition.
The secondary problem it solves, which is often the one that actually drives adoption:
a single schema forces a shared vocabulary. Two teams cannot both define User
differently, because composition fails at CI time. That is an architectural constraint
enforced mechanically, and it is worth more than the query routing.
Mechanics
Entities and keys
An entity is a type that can be referenced and extended across subgraphs. It
declares a @key, the field set that identifies it.
# catalogue subgraph: owns the canonical Product
type Product @key(fields: "id") {
id: ID!
name: String!
price: Money!
}
# reviews subgraph: contributes fields to the SAME type
type Product @key(fields: "id") {
id: ID! # the key, so the router can match
reviews: [Review!]! # this subgraph's contribution
averageRating: Float
}
type Review @key(fields: "id") {
id: ID!
body: String!
author: User!
}
Neither subgraph knows about the other. The router composes them and, for a query touching both, produces a plan.
The query plan
query {
product(id: "P42") {
name # catalogue
price { amount } # catalogue
reviews { # reviews subgraph
body
author { name } # users subgraph
}
}
}
The router executes roughly:
1. catalogue: query { product(id:"P42") { __typename id name price {amount} } }
↑ the router ADDS __typename and the key
2. reviews: query($r:[_Any!]!) {
_entities(representations:$r) {
... on Product { reviews { body author { __typename id } } }
}
}
variables: { r: [{ __typename:"Product", id:"P42" }] }
3. users: query($r:[_Any!]!) {
_entities(representations:$r) {
... on User { name }
}
}
variables: { r: [{__typename:"User", id:"U1"}, {__typename:"User", id:"U7"}] }
4. merge into the client's requested shape
Two mechanics carry the whole design. _entities with representations is the
generated entry point every subgraph must implement: given a list of
{__typename, key} stubs, return the objects. And step 3 batches: all the author
IDs from all the reviews go in one call, which is federation's built-in answer to the
N+1 problem across services. Within a subgraph you still
need DataLoader; across subgraphs the router batches for you.
Implementing the reference resolver in DGS:
@DgsComponent
public class ProductReviewsResolver {
// Called with the key fields for each representation the router sends.
@DgsEntityFetcher(name = "Product")
public Product product(Map<String, Object> values) {
return new Product((String) values.get("id")); // stub; fields resolve below
}
@DgsData(parentType = "Product", field = "reviews")
public CompletableFuture<List<Review>> reviews(DgsDataFetchingEnvironment env) {
Product p = env.getSource();
// DataLoader batches WITHIN this subgraph across the entity list.
return env.getDataLoader(ReviewsDataLoader.class).load(p.getId());
}
}
@DgsEntityFetcher returns a stub carrying only the key; the field resolvers then fill
it in, and the DataLoader batches across every representation in the request. Without
that DataLoader, a query touching 50 products produces 50 database queries in the
reviews subgraph, and the router's cross-service batching does not save you.
What Federation v2 changed
v1's model was "one subgraph owns a type, others extend it," expressed with extend type and @external. It was rigid: only one subgraph could define a field, and the
ownership hierarchy had to be declared.
v2's model is shared ownership with explicit resolution:
| Directive | Purpose |
|---|---|
@shareable | Several subgraphs may resolve this field (identical values expected) |
@override(from: "sub") | This subgraph takes over a field from another: the migration primitive |
@inaccessible | Present in subgraphs, hidden from the public supergraph |
@provides | This subgraph can return some of another's fields, avoiding a hop |
@requires | This field needs another subgraph's field as input |
@external | Declared here only so @requires or @provides can reference it |
@override deserves attention because it is the field-level migration tool, and
migrations are where federation earns its keep:
# The NEW subgraph claims the field. The router sends traffic here instead.
type Product @key(fields: "id") {
id: ID!
price: Money! @override(from: "legacy-catalogue")
}
Deploy the new subgraph with @override, the router shifts that field's traffic, and
the old subgraph can drop it later. One field moves between services with no client
change and no coordinated deploy. Apollo added progressive @override with a
percentage for a gradual shift, which turns a schema migration into a canary.
@requires is the one that causes trouble:
# shipping subgraph
type Product @key(fields: "id") {
id: ID!
weight: Float! @external # owned by catalogue
shippingCost: Money! @requires(fields: "weight")
}
The router must now fetch weight from catalogue before it can call shipping, which
serialises two calls that would otherwise be parallel. A few @requires in a query
path can turn a two-hop plan into a five-hop one, and the latency shows up as
"GraphQL is slow" with no single service to blame.
Composition is a build-time gate
The supergraph schema is compiled from the subgraph schemas, and composition fails on
conflict: two subgraphs defining the same field without @shareable, incompatible
types for the same field name, a @key referencing a field that does not exist.
# In CI, before the subgraph can be published.
rover subgraph check my-graph@prod \
--schema ./schema.graphqls --name reviews
This check is what makes federation safe, and skipping it is the most common operational failure. Without it, a subgraph deploy can break composition and the router either fails to update or serves a stale supergraph, and either way the failure is discovered in production. The check also reports which client operations would break, using recorded field usage, which is a stronger signal than schema-level compatibility alone.
A worked example: three round trips to one, and the latency that got worse first
A retail mobile app. Product detail page needed catalogue data, inventory, reviews and personalised recommendations, from four services with four REST APIs.
Before: the app made four parallel HTTP calls and joined client-side.
p50 page-ready: 740ms
p99 page-ready: 2,900ms
payload: 310 KB (each API returned its full resource)
app code: ~600 lines of orchestration and error handling per platform
The 310 KB was the real complaint: the catalogue API returned 40 fields when the page showed 9, because it was a shared REST resource nobody could trim without breaking another consumer.
They federated: four DGS subgraphs behind an Apollo Router.
First measurement, and it was worse:
p50 page-ready: 610ms (better)
p99 page-ready: 4,100ms (WORSE by 41%)
payload: 28 KB (much better)
The p99 regression traced to two causes, and both are instructive.
Cause 1: @requires serialised the plan. The recommendations subgraph needed the
product's category and priceTier, both owned by catalogue:
type Product @key(fields: "id") {
id: ID!
category: String! @external
priceTier: String! @external
recommendations: [Product!]! @requires(fields: "category priceTier")
}
The router therefore could not call recommendations in parallel with anything; it had to complete the catalogue fetch first. Four parallel calls had become a two-phase plan where the slowest subgraph gated the second phase.
Cause 2: no DataLoader in the reviews subgraph. Recommendations returned 12
products, each needing a rating, so the reviews subgraph received one _entities call
with 12 representations and issued 12 database queries. The router batched across
services and nothing batched within one.
The fixes:
// 1. DataLoader in every subgraph, batching across the representation list.
@DgsDataLoader(name = "ratings", maxBatchSize = 200)
public class RatingsDataLoader implements MappedBatchLoader<String, Rating> {
@Override
public CompletionStage<Map<String, Rating>> load(Set<String> productIds) {
return supplyAsync(() -> ratingRepository.findByProductIds(productIds)); // ONE query
}
}
# 2. Remove @requires by duplicating two cheap, slow-changing fields into the
# recommendations subgraph's own store, marked @shareable in catalogue.
type Product @key(fields: "id") {
id: ID!
category: String! @shareable # both subgraphs can resolve it
recommendations: [Product!]! # no longer gated on a catalogue fetch
}
That second fix is a real trade and worth naming as such: they denormalised two
fields into a second service to remove a serialisation point. The fields change
rarely (a product's category is close to immutable) and the subgraph syncs them from a
catalogue event stream. If they had been volatile, @requires would have been correct
and the latency would have been the price.
After:
REST federated (first) federated (fixed)
p50 page-ready 740ms 610ms 390ms
p99 page-ready 2,900ms 4,100ms 1,150ms
payload 310 KB 28 KB 28 KB
subgraph queries
per page-load 4 17 6
app orchestration
code ~600 LOC ~0 ~0
The payload drop from 310 KB to 28 KB was worth more to users on poor connections than the latency change, and it was the thing REST could not deliver without a breaking change to a shared resource.
The number worth carrying is 17 to 6. The naive federated version issued more backend work than the REST version it replaced, and looked fine in staging where the data volumes were small. Federation makes it easy to write a query whose plan is expensive, and the plan is invisible in the query text.
Production evidence
Netflix built DGS and open-sourced it in 2021, having run federated GraphQL across a large number of subgraphs. Their published account describes moving from a monolithic GraphQL layer to federation specifically for the ownership reason: teams could not deploy independently. DGS exists because Apollo's tooling was Node-first and Netflix's backend is JVM.
Apollo's Federation 2 announcement documented the v1 limitations directly: the
single-owner model forced awkward workarounds, extend type was confusing, and
value types could not be shared. That a specification's second version relaxes its
ownership model is a useful signal about what teams actually needed.
Expedia, Airbnb, PayPal and Wayfair have all published on federated GraphQL adoption. The common thread in their accounts is that the migration is organisational first: agreeing on entity keys and shared types across teams is the hard part, and the router configuration is comparatively easy.
GraphQL Mesh and Cosmo exist as alternative federation implementations, and WunderGraph's Cosmo router is notable for being open source with an Apollo-Federation-compatible query planner, which matters because the Apollo Router's licensing (Elastic License) has pushed some teams to look for alternatives.
Netflix's DGS was donated to the GraphQL Java umbrella and the Spring team
subsequently built spring-graphql, with DGS gaining a Spring GraphQL integration.
The practical guidance now is that new Spring projects can use either, with DGS's
code-first annotations being the differentiator.
The debate
Should you federate at all? The honest answer is that federation is an organisational solution and its cost is technical. You take on a router (another network hop, another thing to operate, another place for a query plan to go wrong), composition checks in CI, and a query planner whose behaviour the average developer does not understand. What you get is independent deployment of a shared schema.
My position: federate when the number of teams contributing to one graph exceeds about three, and not before. Below that, a single GraphQL service with modular resolvers is simpler and has none of the planning complexity. The tell that you have crossed the line is deploy contention: teams waiting on each other to ship a schema change.
Federation versus BFF (backend for frontend). A BFF is a per-client service that aggregates downstream APIs however it likes, with no shared schema and no router. It is simpler, it gives each client team full control, and it duplicates aggregation logic across BFFs. Federation gives one schema and one aggregation implementation, at the cost of a shared artifact everyone must agree on. For two or three clients with very different needs, BFFs are often the better answer, and the honest framing is that federation optimises for a single coherent graph while BFFs optimise for client autonomy.
Is the router a single point of failure? It is on the request path for everything, so yes, and it must be treated as tier 1: multiple replicas, no shared state (the Apollo Router is stateless given a supergraph schema), and a supergraph fetched at startup with a cached fallback so a schema-registry outage does not stop new pods from starting. That last detail is the one teams miss and it turns a registry blip into an inability to scale.
@requires and @shareable: when is denormalisation right? @requires serialises
the plan, and the alternative is duplicating the required field into the consuming
subgraph as @shareable. Duplicate when the field is slow-changing and cheap to
sync (category, tier, type, status enums), and accept the serialisation when it is
volatile or expensive (current price, live inventory), because a stale duplicate of a
volatile field is a correctness bug that is much worse than latency.
Performance transparency is the real weakness. A client can write a query whose plan is six sequential hops, and nothing in the query text says so. The mitigations are real but partial: expose the query plan in traces, set operation-level cost limits (see GraphQL caching and limits), and use persisted queries so only reviewed operations run in production. Without persisted queries, federation gives arbitrary clients the ability to compose expensive plans, and I would treat enabling them as part of the federation rollout rather than a later optimisation.
Follow-up Q&A
"How does the router know which subgraph has which field?"
From the composed supergraph schema, which is built at CI time from every subgraph's
SDL and records, per field, which subgraphs can resolve it. At query time the planner
walks the requested selection set against that map and produces a plan: fetch these
fields from A, then use the returned keys as representations for a _entities call
on B. The @key directive is what makes the second step possible, because it defines
the identity the router passes between subgraphs.
"What is _entities and why does every subgraph need it?"
It is the generated query field federation adds to every subgraph: it takes a list of
representations (each a __typename plus key fields) and returns the corresponding
objects. It is how the router says "here are 12 product IDs, give me your fields for
them." Frameworks generate the plumbing; you implement the reference resolver
(@DgsEntityFetcher in DGS), and that resolver should return a stub carrying the key
while field resolvers with DataLoaders fill in the rest.
"Federation batches across services. Do I still need DataLoader?"
Yes, and this is the most common performance bug. The router batches by sending one
_entities call with many representations. Inside your subgraph, that call resolves N
entities, and without a DataLoader each one issues its own database query. In the
worked example that produced 12 queries for one _entities call. Cross-service
batching and within-service batching are different problems with different solutions.
"What does @requires cost?"
A serialisation point. The router must fetch the required fields from their owning
subgraph before it can call the requiring one, so two calls that would have been
parallel become sequential. Several @requires on one path compound into a deep plan.
The alternative is duplicating the field into the consuming subgraph as @shareable
and syncing it, which is correct for slow-changing fields and a correctness risk for
volatile ones.
"How do you move a field from one subgraph to another with no downtime?"
@override(from: "old-subgraph") on the field in the new subgraph. Publish the new
subgraph, composition routes that field's traffic to it, clients see nothing. Then
remove the field from the old subgraph in a later deploy. Apollo's progressive
@override adds a percentage so the shift can be canaried. This is expand-and-contract
at the field level, and it is the strongest concrete argument for federation over
stitching.
"A federated query is slow. How do you debug it?"
Get the query plan first, because the plan is the thing and it is invisible in the
query text: the Apollo Router emits it in traces and it can be requested explicitly in
development. Read it for depth (how many sequential phases) rather than breadth
(parallel fetches are usually fine). Then look for @requires creating the phases, and
for a subgraph whose _entities resolution is N+1 internally. Distributed tracing with
the router propagating trace context is what makes this tractable; without it you have
a slow endpoint and four services all reporting fast responses.
Common misconceptions
"DGS is an alternative to Apollo Federation." DGS is a framework for building subgraphs that implement the Apollo Federation specification. The normal JVM deployment is DGS subgraphs behind an Apollo Router. They are layers, not competitors.
"Federation means one team no longer owns the schema." Every field is owned by
exactly one subgraph (or explicitly shared with @shareable). What federation removes
is a single team owning the deployment of everyone's schema. Composition checks
enforce the ownership rules mechanically.
"Federation reduces the number of backend calls." It reduces the number of client calls to one. Backend calls can easily increase, as the worked example showed: 4 became 17 before tuning. The client experience improves regardless; the backend cost is a thing you must measure.
"The router is just a proxy." It parses, validates, plans, executes a multi-phase fetch, and merges results. The planner is a real optimiser and its output determines your latency. Treating it as a dumb proxy is how you end up with six sequential hops.
"Schema stitching and federation are the same idea." Stitching puts join configuration in the gateway, so every change is a gateway change. Federation puts declarations in the subgraphs and derives the plan, so a team ships a field without touching shared infrastructure. The difference is where the coupling lives, which is the whole point.
Interview delivery note
Say this verbatim: "Federation is an organisational solution with a technical cost. You take on a router, a query planner and composition checks in CI, and what you get is independent deployment of one shared schema. I would not federate below about three contributing teams, because a single GraphQL service with modular resolvers is simpler and has none of the planning complexity." Leading with the cost and a threshold is what makes it a judgment rather than an endorsement.
The senior-versus-staff separator is knowing that the router's cross-service batching
does not remove the need for DataLoader inside a subgraph. A senior engineer explains
entities, keys and _entities correctly. A staff engineer points out that one
_entities call with 50 representations becomes 50 database queries without a
DataLoader, and that federation therefore makes the N+1 problem harder to see,
because the router's batching creates the impression it is handled.
The second signal is @requires as a latency decision. Saying "I would duplicate
category into the recommendations subgraph as @shareable because it is effectively
immutable, and accept @requires for live price because a stale price is a correctness
bug" shows you are trading two real costs rather than reciting directives.
Further reading
- Apollo Federation 2 specification and the "Federation 2 announcement," particularly
the sections on
@shareable,@overrideand the v1 limitations they address. - Netflix Technology Blog, "Open Sourcing the Netflix Domain Graph Service Framework" (2021), for the motivation and the JVM subgraph model.
- Apollo Router documentation on query planning and the
@requiresexecution model, for how directives translate into fetch phases. - Rover CLI documentation on
subgraph check, for the CI gate that makes independent subgraph deploys safe.