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:

DirectivePurpose
@shareableSeveral subgraphs may resolve this field (identical values expected)
@override(from: "sub")This subgraph takes over a field from another: the migration primitive
@inaccessiblePresent in subgraphs, hidden from the public supergraph
@providesThis subgraph can return some of another's fields, avoiding a hop
@requiresThis field needs another subgraph's field as input
@externalDeclared 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, @override and 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 @requires execution model, for how directives translate into fetch phases.
  • Rover CLI documentation on subgraph check, for the CI gate that makes independent subgraph deploys safe.