Federated GraphQL — Disaggregating an Aggregator

A working introduction to Apollo Federation 2: what it is, how it fits together, and the one operational cost that the documentation tends to underplay.

Callum Hubbarde
AuthorCallum Hubbarde
Published9th February 2024
TopicSoftware Development
Reading time8 min read

The problem with a monolithic gateway

There is a particular kind of architectural moment that tends to arrive without much fanfare, usually somewhere around the point at which your third or fourth engineering team joins a project and begins making demands on the same GraphQL schema as everyone else. Up until that point, a single gateway has been working rather well. It sits in front of your services, aggregates the responses, and presents a unified API to your clients. One schema, one deployment pipeline, one team responsible for it all. Coherent, manageable, and entirely sufficient for the task at hand.

The trouble arrives gradually, and then rather suddenly. The gateway schema, which was once a simple document owned by one team, becomes a shared artefact that nobody quite owns but everyone depends on. A schema change from the product catalogue team blocks the release from the orders team. A new field on the user type requires a co-ordination meeting with three separate leads. Deployments begin to queue. What was once a clean abstraction has quietly become a bottleneck. The monolithic gateway, in the technical sense, has run its course. Federated GraphQL is the answer to this specific problem, and it is, on balance, a rather elegant one.

How federation thinks about data

The central idea in GraphQL Federation is that a type can be owned by one subgraph and referenced or extended by others. The Products service defines the canonical Product type. The Inventory service can add stock information to that type without the Products team needing to know or care about inventory at all. The Orders service can include Product in its line items, pulling whichever fields it needs from whichever subgraph owns them. Each team ships when they are ready, against a schema they control, and the composed graph reflects all of it.

What makes this possible is the @key directive, which designates one or more fields as the unique identifier for an entity across the federated graph. When the Apollo Router receives a query spanning multiple subgraphs, such as an order requiring product names from the Products service and stock levels from the Inventory service, it uses these keys to plan the necessary downstream requests and stitch the results together before returning a single response to the client. From the client's perspective, none of this co-ordination is visible; they are simply querying a graph, as they always were.

The Apollo Router is the orchestration layer in all of this: a Rust-based binary that handles query planning and execution across your subgraphs. It is configured via YAML rather than code, which is rather pleasant, and it requires no modifications to your individual subgraph services to introduce. You point it at your schemas and your service URLs, and it handles the rest.

Setting up the supergraph

For the purposes of illustration, consider an e-commerce platform with three independent services: a product catalogue, an inventory system, and an order management service, each running as a Node.js GraphQL API. The Products subgraph defines the Product entity and establishes the @key that other subgraphs will use to reference it.

products.graphql
extend schema
  @link(url: "https://specs.apollo.dev/federation/v2.0", import: ["@key"])

type Query {
  product(id: ID!): Product
  products: [Product!]!
}

type Product @key(fields: "id") {
  id: ID!
  name: String!
  description: String
  price: Float
}

The @key(fields: "id") directive is the declaration that makes Product a federated entity. It tells the Router that any subgraph may reference a Product by its id field, and that the Products subgraph is responsible for resolving the canonical fields. This is rather central to how the whole thing fits together, and we shall be returning to it before long.

The Apollo Router is configured via a supergraph.yaml file, which specifies where each subgraph can be reached and where its schema file is located for composition.

supergraph.yaml
federation_version: =2.0.0

supergraph:
  listen: 0.0.0.0:4000

subgraphs:
  products:
    routing_url: http://products-service:4001/graphql
    schema:
      file: ./products.graphql
  inventory:
    routing_url: http://inventory-service:4002/graphql
    schema:
      file: ./inventory.graphql
  orders:
    routing_url: http://orders-service:4003/graphql
    schema:
      file: ./orders.graphql

At startup, the Router composes these schemas into a unified supergraph and validates that all entity references are consistent across the subgraphs. This composition step will refuse to complete if any subgraph's types conflict with another's, a constraint that proves rather useful, as we shall see.

Extending entities across subgraphs

With the Products subgraph in place, the Inventory team can begin extending the Product type without requiring any access to the Products codebase. They declare a stub of the type, mark the key field as @external to indicate it is owned elsewhere, and add their own fields alongside it.

inventory.graphql
extend schema
  @link(url: "https://specs.apollo.dev/federation/v2.0", import: ["@key", "@external"])

type Product @key(fields: "id") {
  id: ID! @external
  stockLevel: Int!
  warehouseLocation: String
}

type Query {
  lowStockProducts(threshold: Int!): [Product!]!
}

The Orders subgraph follows the same pattern, referencing Product in its line items by key alone. It need not know anything about the Product type beyond the fact that it can be identified by an id.

orders.graphql
extend schema
  @link(url: "https://specs.apollo.dev/federation/v2.0", import: ["@key", "@external"])

type Order @key(fields: "id") {
  id: ID!
  lineItems: [LineItem!]!
  status: OrderStatus!
}

type LineItem {
  product: Product!
  quantity: Int!
}

type Product @key(fields: "id") {
  id: ID! @external
}

enum OrderStatus {
  PENDING
  CONFIRMED
  SHIPPED
  DELIVERED
}

type Query {
  order(id: ID!): Order
  ordersByUser(userId: ID!): [Order!]!
}

A client wishing to retrieve an order with full product and stock details might write something like this:

example-query.graphql
query GetOrderDetails($orderId: ID!) {
  order(id: $orderId) {
    id
    status
    lineItems {
      quantity
      product {
        name
        price
        stockLevel
      }
    }
  }
}

The Router plans this query across all three subgraphs, fetching the order from the Orders service, resolving each product's name and price from the Products service, and retrieving stock levels from the Inventory service, before returning a single composed response. That three entirely separate services have been involved is, from the client's perspective, invisible. Which is, when you see it working for the first time, rather satisfying.

The two-step problem

Federation handles independent deployability rather elegantly, right up until you need to change a field that multiple subgraphs depend on. At this point it is worth understanding what might reasonably be called the two-step problem, and it is perhaps most clearly illustrated through a change that development teams tend to make with some regularity: correcting the representation of money.

A common early mistake, and one that appears in rather more production systems than one might hope, is to represent monetary values as Float. The appeal is obvious enough: prices are decimal numbers, Float is a decimal type, and the connection seems natural. The problem is that floating-point arithmetic is not suited to monetary calculations. 0.1 + 0.2 in a floating-point system does not equal 0.3; it equals something close to 0.3 but not quite, and in a checkout flow that particular imprecision has a tendency to compound in ways that are, at minimum, embarrassing. Our Products subgraph, as currently defined, contains exactly this mistake.

products.graphql — before
type Product @key(fields: "id") {
  id: ID!
  name: String!
  price: Float  # this is wrong
}

The correct approach is to store monetary values as integers in the smallest denomination (pence for GBP, cents for USD) and define a proper Money type that makes the currency explicit alongside the amount. In a monolithic schema this is a single migration: change the type, update the resolvers, deploy once. In a federated graph it is rather more involved, because both the Inventory and Orders subgraphs are referencing price on Product. You cannot update all three subgraphs simultaneously. There is always a deployment window during which one subgraph has the new type and the others have not yet caught up, and during that window the Router's schema composition will fail and it will decline to serve requests.

The safe approach proceeds in two deliberate steps. First, introduce the new Money type and a new field, priceDetails: Money, into the Products subgraph, whilst retaining the old price: Float field and marking it @deprecated. Update the Inventory and Orders subgraphs to reference the new field. Deploy all three. The graph remains valid throughout this transition, because both fields coexist.

products.graphql — step 1
type Money {
  amount: Int!       # stored in pence or cents
  currency: String!  # ISO 4217, e.g. "GBP"
}

type Product @key(fields: "id") {
  id: ID!
  name: String!
  price: Float @deprecated(reason: "Use priceDetails instead. Will be removed once all consumers have migrated.")
  priceDetails: Money
}

Once all consumers have migrated to priceDetails and the deprecated field is no longer referenced anywhere, you can remove price: Float from the Products subgraph entirely and deploy for the final time.

products.graphql — step 2
type Money {
  amount: Int!
  currency: String!
}

type Product @key(fields: "id") {
  id: ID!
  name: String!
  priceDetails: Money!
}

It is worth noting that this pattern is not unique to federation. Any distributed system where multiple consumers depend on a shared contract will encounter some version of it. What federation makes particularly visible is the constraint, because the Router enforces schema composition at startup and will not serve a graph with type conflicts. That strictness is, on reflection, probably the right design decision; it prevents an entire class of runtime type errors that would otherwise be rather difficult to diagnose in production, where they would almost certainly surface at the least convenient possible moment.

When does it pay off?

The honest answer is that federation earns its complexity at a fairly specific moment in a product's evolution: when multiple teams are shipping against the same schema, when the deployment pipeline is visibly queuing, and when the cost of co-ordination is becoming measurably higher than the cost of running a distributed system. A single team building a monolith does not need federation, and introducing it speculatively, on the grounds that one might need it eventually, is the kind of architectural decision that tends to be quietly regretted.

For teams at the right scale, however, federation is a rather elegant solution to a genuinely difficult problem. The operational overhead is real but front-loaded: once the Router is configured and the subgraphs are composing cleanly, the day-to-day development experience is largely unchanged for individual teams. They write GraphQL. They deploy when they are ready. The Router handles the rest.

Just be aware, when the time comes to change a shared field, that the two-step problem will be waiting for you. It is entirely manageable once you know it is coming.

More from Tech Relays

Get started

Ready to build something?

Tell us about your project and we'll come back to you within 24 hours.

Let's Talk →