GraphQL schemas are typically monolithic, defined in a single file. But what if you have multiple microservices, each with its own GraphQL API, and you want to present a unified, single GraphQL endpoint to your clients? This is where schema stitching comes in. It allows you to combine multiple GraphQL schemas into one, so clients can query data across different services as if it were all coming from a single source.

Let’s see it in action. Imagine we have two simple services: a UserService and an OrderService.

UserService Schema (userSchema.graphql):

type User {
  id: ID!
  name: String!
  email: String
}

type Query {
  user(id: ID!): User
  users: [User!]!
}

OrderService Schema (orderSchema.graphql):

type Order {
  id: ID!
  userId: ID!
  amount: Float!
  date: String
}

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

Now, we want to create a single gateway that exposes both User and Order types, and crucially, allows us to query for a user and their orders in a single request.

We’ll use Apollo Federation, a popular framework for schema stitching. The core idea is that each service defines its own schema and then exports it. A gateway service then fetches these schemas and combines them.

Here’s a simplified gateway setup using Node.js and Apollo Server:

// gateway.js
import { ApolloServer } from '@apollo/server';
import { startStandaloneServer } from '@apollo/server/standalone';
import { mergeSchemas } from '@graphql-tools/merge';
import { loadSchemaSync } from '@graphql-tools/load';
import { GraphQLFileLoader } from '@graphql-tools/graphql-file-loader';
import { RemoteGraphQLDataSource } from '@apollo/gateway';
import { Gateway } from '@apollo/gateway';

// In a real scenario, these would be actual service URLs
const userServiceUrl = 'http://localhost:4001/graphql';
const orderServiceUrl = 'http://localhost:4002/graphql';

// The gateway will dynamically fetch schemas and stitch them.
// Here we configure the data sources for the gateway.
const gateway = new Gateway({
  serviceList: [
    { name: 'users', url: userServiceUrl },
    { name: 'orders', url: orderServiceUrl },
  ],
});

const server = new ApolloServer({
  gateway,
  // Subscriptions are disabled by default in Gateway mode.
  // If you need them, enable them here.
  subscriptions: false,
});

const { url } = await startStandaloneServer(server, {
  listen: { port: 4000 },
});

console.log(`🚀 Gateway ready at ${url}`);

With this gateway running, a client can now send a query like this:

query GetUserAndTheirOrders($userId: ID!) {
  user(id: $userId) {
    id
    name
    email
    orders { # This 'orders' field is not directly in the UserService schema!
      id
      amount
      date
    }
  }
}

The gateway, using Apollo Federation, understands that the orders field on the User type is actually a remote field that needs to be resolved by the OrderService. It will automatically perform the necessary sub-queries. When the user query is executed, the gateway fetches the user data from the UserService. Then, it sees the orders field and knows it needs to query the OrderService. It will take the id of the user it just fetched and use it to call the ordersForUser query on the OrderService. The results are then combined and returned to the client.

The mental model for schema stitching, especially with federation, is that each service is a partial schema that declares its types and how other services can extend them. The gateway acts as an orchestrator. It has a supergraph schema, which is the merged schema of all services. When a query comes in, the gateway’s query planner breaks it down into sub-queries that are sent to the appropriate services.

In Apollo Federation, services declare "entities." An entity is a type that can be uniquely identified across services. In our example, User is an entity. The Order service can then extend the User type to add the orders field.

UserService Schema (userSchema.graphql) with Federation directives:

type Query {
  user(id: ID!): User
  users: [User!]!
}

type User @key(fields: "id") { # Declare User as an entity with 'id' as its key
  id: ID!
  name: String!
  email: String
}

OrderService Schema (orderSchema.graphql) with Federation directives:

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

type Order {
  id: ID!
  userId: ID!
  amount: Float!
  date: String
}

extend type User @key(fields: "id") { # Extend the User type from another service
  id: ID! # Must include the key fields
  orders: [Order!]! @requires(fields: "id") # This field requires the user's ID
}

Notice the extend type User in the OrderService. This tells the gateway that the OrderService provides data for the User type, specifically the orders field. The @requires(fields: "id") directive indicates that to resolve the orders field, the OrderService needs the id of the user. The gateway will ensure this id is available when it makes the request to the OrderService.

The most surprising thing about how schema stitching works under the hood is how type merging and field resolution are handled. When a field like User.orders is requested, and it’s defined in a different service, the gateway doesn’t just make a single call to the OrderService with the user’s ID. Instead, it uses the @key directive to establish relationships. The gateway first queries the UserService for the User entity. Then, it collects all the ids of the users requested in that operation. It then makes a single call to the OrderService, passing all those user IDs to its ordersForUser resolver (if the OrderService is optimized to accept multiple IDs, which is common). This batching is crucial for performance, preventing N+1 query problems.

The next hurdle after stitching is often managing distributed caching and ensuring data consistency across services.

Want structured learning?

Take the full Graphql-tools course →