I have been in enough architecture reviews to recognize the moment federation goes from whiteboard concept to “why is this query taking four seconds.” Three years ago, I helped a fintech client untangle a mess of six different GraphQL endpoints their teams had organically grown. Each team had built their own schema. Each schema had its own authentication. Product engineers were stitching data together in the frontend. It was, as I told their CTO, a distributed monolith wearing a federated hat.
GraphQL Federation, done right, solves a real problem. Done wrong, it introduces coordination overhead that makes REST microservices look elegant. This article is about doing it right.
What Federation Actually Solves
Before we get into architecture, let us be precise about the problem. If you have one team and one backend, you do not need federation. If you are debating GraphQL vs REST at this stage, federation is not part of that conversation.
Federation becomes relevant when you have this configuration: multiple backend teams, each owning distinct domains, and a frontend (or multiple frontends) that needs to query across those domains without stitching data in the client. The classic example is an e-commerce platform where an Orders team, a Products team, and a Users team each own their slice of the data model, but the checkout page needs a User with their recent Orders and the associated Product details.
Without federation, you get one of three outcomes. Either you create a backend for frontend service that calls all three APIs and stitches the response, which becomes a bottleneck. Or you expose all three APIs to the client, which pushes coordination logic to the frontend. Or you build one giant GraphQL schema owned by no one, which creates a coordination problem between teams that eventually makes velocity grind to a halt.
Federation gives each team their own subgraph with its own schema, deployed and versioned independently, while a router composes them into a unified supergraph that clients query through a single endpoint.

Apollo Federation v2 and the Router
The history here matters because a lot of outdated guidance is still floating around. Apollo Federation v1 used a JavaScript-based gateway that was slow, hard to operate, and had schema composition limitations. Federation v2 rewrote the composition rules and, more importantly, introduced the Apollo Router, written in Rust. The Router is meaningfully faster than the old gateway, handles backpressure better, and has become the production standard.
I want to separate two things the Apollo ecosystem bundles together: the Federation specification and the Apollo toolchain. The Federation specification, at its core, is an open standard. You can run federated subgraphs without any Apollo tooling on the subgraph side. The Router, however, is where the composition happens, and while the specification is open, the Router’s feature set is where Apollo monetizes. The fully-featured distributed tracing, persisted queries, and schema registry integration live behind GraphOS subscriptions.
For most production deployments you have two paths. Path one is managed federation through Apollo GraphOS: you publish subgraph schemas to a schema registry, GraphOS validates composition and produces a supergraph schema, and the Router polls for updates. Path two is self-managed: you run the Rover CLI in CI to compose schemas yourself and feed the supergraph schema to the Router. The first path is operationally simpler. The second gives you full control and avoids the GraphOS dependency.
I have run both. For teams with mature CI pipelines who take infrastructure seriously, self-managed is fine. For teams that need the schema registry features and want ops to focus elsewhere, GraphOS is worth the cost.
Subgraph Design: Where Production Pain Lives
The subgraph is where every federation failure I have seen originates. Teams treat subgraphs like independent GraphQL services with some extra annotations and then wonder why the Router’s query plan is doing eleven round trips to serve one frontend page.
The critical primitive in federation is the entity. An entity is a type that can be referenced and extended across subgraph boundaries. You mark a type as an entity using the @key directive:
# In the Users subgraph
type User @key(fields: "id") {
id: ID!
email: String!
displayName: String!
}
# In the Orders subgraph
type Order @key(fields: "id") {
id: ID!
createdAt: String!
user: User!
}
extend type User @key(fields: "id") {
id: ID! @external
orders: [Order!]!
}
When the Router sees a query asking for Order.user.email, it knows it needs to first fetch the order from the Orders subgraph to get the user ID, then call the Users subgraph’s _entities resolver with that ID to fetch the User. This is called entity resolution, and it is where latency hides.
The naive implementation hits the Users subgraph once per Order. If you are returning a list of 50 orders and each has a different user, you have just issued 50 sequential requests. The fix is DataLoader, a batching pattern that consolidates multiple entity lookups into a single batched call. Every subgraph I deploy to production has DataLoader wiring for entity resolution. If yours does not, it will when you add it to the performance post-mortem.
The @key Directive and Entity Resolution Patterns
One of the things Federation v2 improved significantly is multi-field and compound keys. You are not limited to a single ID field as the @key:
type Product @key(fields: "sku warehouse") {
sku: String!
warehouse: String!
name: String!
inventory: Int!
}
Federation v2 also introduced progressive @override, which solves a migration problem I ran into on a platform consolidation project. When you are migrating a field from one subgraph to another, you need a period where both subgraphs serve the field without duplication issues. The @override directive lets you mark that a field is being moved, with a progressive rollout percentage:
type Product @key(fields: "id") {
id: ID!
price: Float! @override(from: "legacy-catalog", label: "percent(50)")
}
This alone saved us a painful big-bang cutover that would have required coordinating deployments across two teams simultaneously. Instead, we ramped traffic gradually, watched error rates, and completed the migration over two weeks without any client-visible disruption.
Query Planning: What the Router Actually Does
The Router’s query planner takes an incoming GraphQL operation and produces a query plan: a set of fetch operations against subgraphs, some of which can run in parallel and some of which depend on results from prior fetches. Understanding the query plan is essential for debugging performance problems.

The Router exposes the query plan at a debug endpoint when you configure include_subgraph_errors: all. Every time I add a new cross-subgraph relationship, I pull the query plan and look at the fetch sequence. What you want to see is parallelism at the top of the plan. What you do not want to see is a deep chain of sequential fetches.
The most common cause of sequential fetch chains is over-normalized schemas. When teams each own their slice of the entity graph cleanly but do not expose enough fields to satisfy queries without deep resolution chains, the Router has no choice but to execute fetches in sequence. The fix is usually adding fields to entities that are frequently co-accessed, accepting some denormalization at the schema layer in exchange for fewer round trips.
For real-time data, I typically recommend keeping subscriptions in a single subgraph and using them directly rather than routing subscription operations through the supergraph. The Router can compose subscription operations across subgraph boundaries, but the operational overhead rarely justifies it for most applications.
Schema Registry and Change Management
The biggest organizational benefit of federation is independent team velocity. The biggest organizational risk is that teams make breaking changes to their subgraphs without realizing the downstream impact. An Orders team that removes a field or changes a type can break the Users team’s frontend queries even though the change was “internal” to the Orders subgraph.
A schema registry is non-negotiable at any meaningful scale. With Apollo GraphOS, schema checks run automatically on every proposed subgraph update and flag breaking changes against real query usage. With self-managed federation using the Rover CLI, you run schema checks in CI as a required gate:
rover subgraph check my-graph@production \
--schema ./schema.graphql \
--name orders
The Rover CLI checks the proposed schema against the current supergraph composition and against recorded query usage. It distinguishes between a field that appears in active queries (a breaking change that will cause client failures) and a field with zero recent usage (safe to remove). This is the same class of tool as feature flags for schema evolution: you should be running it on every pull request before merge.
The schema registry discussion is also where alternatives to Apollo’s stack enter the picture. WunderGraph Cosmo is an open-source alternative that includes its own router, schema registry, and observability tooling. Grafana Hive (from The Guild) provides a GraphQL schema registry that is compatible with Apollo Federation subgraphs but lets you run your own router. For teams that want to avoid Apollo’s commercial licensing for GraphOS features, these are worth evaluating. I deployed Cosmo for a client that had regulatory requirements preventing SaaS tooling for schema management, and it handled their 15-subgraph deployment without issues.
Production Operations
Federation adds operational dimensions that a monolithic GraphQL API does not have. The Router is now a critical path component in every API call. The first thing I do with a new federation deployment is set up distributed tracing across subgraph boundaries.
The Router natively supports OpenTelemetry, and every subgraph should propagate trace context. When you have proper distributed tracing in place, a slow query becomes diagnosable: you can see exactly which subgraph fetch took the time, whether it was entity resolution or the root fetch, and how the query plan executed. Without this, you are debugging blind. I’ve spent too many hours in production incidents trying to correlate log timestamps across services when proper trace propagation would have shown the problem in ten seconds.
Caching in a federated setup requires thought. The Router supports response caching through CDN headers and through its own caching layer. The nuance is that different parts of a composed response may have different cacheability. Product data changes rarely; user cart data changes frequently. When these fields appear in the same query response, you are dealing with a composed cache TTL that defaults to the shortest component. I typically handle this by structuring queries so highly dynamic data comes from separate operations rather than composing it with slow-changing catalog data.
Rate limiting at the federation layer is more nuanced than at a simple API gateway. You can rate limit at the Router, which is the right place for per-client or per-IP limits. But you also need to think about the amplification factor: a single client query might produce dozens of subgraph fetches. A deeply nested query against a federated schema can blow up into a fan-out at the subgraph level that overwhelms a downstream service. Query depth limits and query complexity limits at the Router are essential:
# router.yaml
limits:
max_depth: 15
max_height: 200
max_aliases: 30
max_root_fields: 20
I set these conservatively and then open them up based on observed query patterns. Starting permissive and tightening after an incident is always harder.
Authentication and authorization in a federated setup is another operational consideration. My standard approach is to authenticate at the Router (verify the JWT, extract claims) and pass claims downstream to subgraphs via forwarded headers. Each subgraph then applies its own authorization logic based on the forwarded claims. Trying to do authorization purely at the Router against a federated schema gets complicated quickly because the Router does not have subgraph-specific domain knowledge. The Router knows routing. Subgraphs know their domain.
This intersects with how the API gateway sits in your architecture. In most deployments I have built, the Router runs behind a standard API gateway that handles the perimeter concerns: DDoS protection, TLS termination, and authentication at the edge. The Router handles GraphQL-specific concerns: query planning, subgraph routing, and response composition. These are complementary layers, not competing ones.
Alternatives to Apollo Federation
Apollo Federation is the dominant standard but not the only option. For teams building in Java or Kotlin, Netflix’s DGS framework has mature federation support and integrates well with Spring Boot workloads. For Python shops, Strawberry and Ariadne both support federation. The subgraph specification is open enough that polyglot subgraph implementations are straightforward.
The Router, however, has less competition if you want production-grade performance and features. Apollo Router’s Rust core is meaningfully faster than any JavaScript or Python gateway implementation I have benchmarked. WunderGraph’s Cosmo Router, written in Go, is the most serious alternative for teams avoiding the Apollo commercial stack.
One architecture I have used successfully for smaller deployments is running the Router as a sidecar alongside a service mesh, rather than as a standalone gateway. The service mesh handles mTLS and traffic management between services while the Router handles GraphQL-specific routing. They operate at different layers and compose well.
For teams considering federation as a path to consolidating APIs, I want to be direct about one failure mode: federation does not fix backend domain modeling problems. I have seen teams try to use federation as a way to avoid the hard work of defining clear bounded contexts. They create a federated schema that mirrors their messy database joins and wonder why the query plans are incomprehensible. Federation is a way to scale well-designed domain models across teams. It does not replace the domain design work.
When Not to Use Federation
Twenty years of building distributed systems has taught me that the most important architecture skill is knowing when not to add complexity. Federation adds real complexity. You now have a Router to operate, a schema registry to maintain, a composition step in your CI pipeline, and a distributed tracing setup across every subgraph.
If you have fewer than three teams with shared data needs, start with a well-structured monolithic GraphQL schema and a single deployment. You can always federate later. The strangler fig pattern applies here: incrementally extract subgraphs as team boundaries crystallize rather than building the full federation topology upfront.
If your clients are primarily mobile apps with a single backend for frontend service, the BFF pattern may be simpler than federation. The BFF knows the client’s data needs, can batch calls efficiently, and does not require client developers to think about federated queries.
If your data does not have natural entity relationships that span teams, you probably have independent services that are better served by separate API endpoints than a federated supergraph. Not every multi-service architecture benefits from a unified graph.

Production Readiness Checklist
For teams ready to move forward, here is the checklist I use before calling a federated deployment production-ready.
On the subgraph side: every entity type has a @key defined; entity resolvers use DataLoader for batching; schema changes go through Rover check in CI before merge; subgraphs publish their schema to the registry on every deployment; subgraphs instrument with OpenTelemetry and propagate trace context in outbound responses.
On the Router side: health check and readiness endpoints configured; query depth and complexity limits set; OpenTelemetry exporter configured and connected to your tracing backend; caching headers set on time-stable fields; authentication claim forwarding configured for downstream subgraph consumption; persisted queries enabled for production traffic (this prevents query enumeration and speeds up large operations by sending a query ID instead of the full query string).
On the operational side: schema change runbook defined (who approves breaking changes, how are clients notified, what is the rollback path); subgraph incident isolation tested (what happens when one subgraph goes down, does the Router return partial data or fail completely); load testing on the Router itself, since it is now in the critical path for every API call; schema registry backup documented.
That fintech client I mentioned at the start? Six months after we untangled their GraphQL mess into five clean subgraphs, their platform team estimated the schema composition saved them roughly three hours per week in coordination meetings between backend teams. Product engineers stopped building stitching logic in the frontend. The checkout page went from four sequential API calls to one federated query. That is the outcome federation is supposed to deliver. It takes work to get there, but the abstraction, when you build it right, genuinely pays off.
Get Cloud Architecture Insights
Practical deep dives on infrastructure, security, and scaling. No spam, no fluff.
By subscribing, you agree to receive emails. Unsubscribe anytime.
