GraphQL Schema Contract Testing: Preventing Breaking Changes with Inspector
GraphQL schemas evolve, and every change carries the risk of breaking clients. Contract testing catches breaking changes before deployment by comparing schema versions and validating that client queries still work against the new schema. This post covers what counts as a breaking change, how to use GraphQL Inspector for schema diffing, consumer-driven contract patterns, and CI integration that blocks deployments when contracts are violated.
Key Takeaways
Not all schema changes are breaking. Adding fields, types, and optional arguments is safe. Removing or renaming fields, changing types, and adding required arguments are breaking — know the difference before you merge. GraphQL Inspector automates schema comparison. It can diff two schema versions, check that client operations are valid against a schema, and integrate directly into GitHub pull requests. Consumer-driven contracts flip the ownership. Instead of the server team guessing what's safe, clients publish the operations they depend on — the server tests against those operations. Apollo Schema Registry provides a managed contract store. For teams using Apollo Router, the registry tracks schema versions and checks operations from connected clients automatically. Backward-compatible evolution is a skill. Learn the patterns: deprecate before remove, add optional fields, use input object extensions — and your schema can evolve without coordinating deployments.
GraphQL's type system gives clients strong guarantees about response shapes. Those same guarantees make schema changes risky: remove a field and every client that queries it breaks silently. Change a field's type from String to Int and clients get runtime errors they didn't see coming.
Contract testing solves this by making the contract between schema and clients explicit and machine-checkable. This post walks through the full picture: what breaks, how to detect it automatically, and how to build a CI workflow that prevents breaking changes from reaching production.
What Constitutes a Breaking Change
Not every schema change is breaking. Additive changes are safe; removals and modifications are dangerous.
Breaking changes:
# BREAKING: Removing a field
type User {
id: ID!
name: String!
# email removed — any client querying { user { email } } now gets an error
}
# BREAKING: Making a nullable field non-null
type Post {
publishedAt: DateTime! # was DateTime — existing null values now violate type contract
}
# BREAKING: Changing a field's type
type Order {
total: Int! # was Float! — clients expecting decimals now get truncated integers
}
# BREAKING: Adding a required argument
type Query {
products(category: String!): [Product!] # was products: [Product!] — existing calls fail
}
# BREAKING: Renaming a type
type Article { # was Post — fragments and __typename checks break
id: ID!
}
# BREAKING: Removing an enum value
enum Status {
ACTIVE
# DELETED removed — clients checking for DELETED now have dead code at best
}Non-breaking (additive) changes:
# Safe: Adding a new field
type User {
id: ID!
name: String!
email: String!
avatarUrl: String # new optional field
}
# Safe: Adding an optional argument
type Query {
products(category: String): [Product!] # existing calls still work
}
# Safe: Adding a new type
type Tag {
id: ID!
name: String!
}
# Safe: Adding a new enum value (but be careful — exhaustive switch statements in clients)
enum Status {
ACTIVE
DELETED
ARCHIVED # new value
}
# Safe: Deprecating a field (not removing)
type User {
name: String! @deprecated(reason: "Use firstName and lastName instead")
firstName: String!
lastName: String!
}GraphQL Inspector: Schema Diffing
GraphQL Inspector is an open-source tool that compares two schema versions and reports breaking changes:
npm install --save-dev @graphql-inspector/cliBasic usage — compare a new schema against an old one:
npx graphql-inspector diff schema-old.graphql schema-new.graphqlOutput for a breaking change:
❌ Field 'User.email' was removed
❌ Field 'Post.publishedAt' changed type from 'DateTime' to 'DateTime!'
⚠️ Field 'User.name' is deprecatedInspector can also validate that existing client operations are valid against the new schema:
npx graphql-inspector validate './src/**/*.graphql' <span class="hljs-string">'./schema.graphql'This catches the case where a schema change is technically valid GraphQL but breaks a specific client operation:
❌ Field 'email' is not defined in 'User' type (in GetUserProfile.graphql:5)
❌ Variable '$category' has wrong type. Expected 'String', got 'String!' (in ListProducts.graphql:1)Schema Files vs. Live Endpoints
Inspector can diff against a live GraphQL endpoint using introspection:
# Compare local schema file against deployed API
npx graphql-inspector diff \
schema.graphql \
http://api.production.example.com/graphqlOr introspect two live endpoints:
npx graphql-inspector diff \
http://api.staging.example.com/graphql \
http://api.production.example.com/graphqlCI Integration
Integrate schema checks into your CI pipeline so breaking changes block pull requests. Here's a GitHub Actions workflow:
# .github/workflows/schema-check.yml
name: GraphQL Schema Check
on:
pull_request:
paths:
- 'src/schema/**'
- 'schema.graphql'
jobs:
schema-diff:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0 # Need full history for base branch comparison
- uses: actions/setup-node@v4
with:
node-version: '20'
- run: npm ci
- name: Get base schema
run: |
git show origin/main:schema.graphql > schema-base.graphql
- name: Check for breaking changes
run: |
npx graphql-inspector diff \
schema-base.graphql \
schema.graphql \
--fail-on-breaking
- name: Validate client operations
run: |
npx graphql-inspector validate \
'src/**/*.graphql' \
schema.graphql
- name: Annotate PR with diff
if: always()
uses: graphql-inspector/github-action@v2
with:
schema: 'schema.graphql'
endpoint: ${{ secrets.STAGING_GRAPHQL_URL }}For a custom diff script with more control:
// scripts/check-schema.js
const { diff } = require('@graphql-inspector/core');
const { loadSchema } = require('@graphql-tools/load');
const { GraphQLFileLoader } = require('@graphql-tools/graphql-file-loader');
async function checkSchema() {
const oldSchema = await loadSchema('schema-base.graphql', {
loaders: [new GraphQLFileLoader()]
});
const newSchema = await loadSchema('schema.graphql', {
loaders: [new GraphQLFileLoader()]
});
const changes = await diff(oldSchema, newSchema);
const breaking = changes.filter(c => c.criticality.level === 'BREAKING');
const dangerous = changes.filter(c => c.criticality.level === 'DANGEROUS');
const nonBreaking = changes.filter(c => c.criticality.level === 'NON_BREAKING');
console.log(`\nSchema changes detected:`);
console.log(` Breaking: ${breaking.length}`);
console.log(` Dangerous: ${dangerous.length}`);
console.log(` Safe: ${nonBreaking.length}`);
if (breaking.length > 0) {
console.error('\nBreaking changes:');
breaking.forEach(c => {
console.error(` ❌ ${c.message}`);
});
process.exit(1);
}
if (dangerous.length > 0) {
console.warn('\nDangerous changes (review required):');
dangerous.forEach(c => {
console.warn(` ⚠️ ${c.message}`);
});
}
}
checkSchema().catch(err => {
console.error(err);
process.exit(1);
});Consumer-Driven Contract Testing for GraphQL
Schema diffing catches potential breaks, but it doesn't tell you whether the change actually affects any real client. Consumer-driven contract testing flips this: clients publish the specific operations they use, and the server validates that those operations still work after changes.
Extracting Client Operations as Contracts
Each client team publishes their queries as contract files:
# contracts/web-app/operations.graphql
# Consumer: web-app v2.4
# Owner: frontend-team@example.com
query GetUserProfile($userId: ID!) {
user(id: $userId) {
id
name
email
avatarUrl
}
}
query ListPublishedPosts($limit: Int, $cursor: String) {
posts(first: $limit, after: $cursor, status: PUBLISHED) {
edges {
node {
id
title
publishedAt
author {
name
avatarUrl
}
}
cursor
}
pageInfo {
hasNextPage
endCursor
}
}
}
mutation UpdateProfile($input: UpdateProfileInput!) {
updateProfile(input: $input) {
id
name
email
}
}Validating Contracts in CI
// scripts/validate-contracts.js
const { validate } = require('@graphql-inspector/core');
const { loadSchema } = require('@graphql-tools/load');
const { loadDocuments } = require('@graphql-tools/load');
const { GraphQLFileLoader } = require('@graphql-tools/graphql-file-loader');
const glob = require('glob');
const path = require('path');
async function validateContracts() {
const schema = await loadSchema('schema.graphql', {
loaders: [new GraphQLFileLoader()]
});
const contractFiles = glob.sync('contracts/**/*.graphql');
let totalErrors = 0;
for (const contractFile of contractFiles) {
const consumer = path.dirname(contractFile).split('/').pop();
const documents = await loadDocuments(contractFile, {
loaders: [new GraphQLFileLoader()]
});
const errors = validate(schema, documents);
if (errors.length > 0) {
console.error(`\n❌ Contract violations for consumer: ${consumer}`);
errors.forEach(err => {
console.error(` - ${err.message}`);
});
totalErrors += errors.length;
} else {
console.log(`✓ ${consumer}: all operations valid`);
}
}
if (totalErrors > 0) {
console.error(`\nTotal contract violations: ${totalErrors}`);
process.exit(1);
}
}
validateContracts();Apollo Schema Registry
For teams on Apollo Router, the Schema Registry provides a managed version of this workflow. Register your schema with rover:
npm install -g @apollo/rover
# Publish schema to registry
rover graph publish my-graph@production \
--schema schema.graphql
<span class="hljs-comment"># Check proposed schema against registered client operations
rover graph check my-graph@production \
--schema proposed-schema.graphqlThe check command returns the list of registered client operations that would be broken by the proposed schema. Teams using Apollo Studio get this as a pull request status check.
Backward-Compatible Evolution Patterns
When you need to make a breaking change, there are patterns that let you evolve the schema without breaking clients.
Deprecate Before Remove
Add the @deprecated directive to signal the change is coming, then remove after clients have migrated:
type User {
# Phase 1: Add new fields, deprecate old
name: String! @deprecated(reason: "Use firstName and lastName. Removed in v3.")
firstName: String!
lastName: String!
}
# Phase 2 (next major version): Remove deprecated field
type User {
firstName: String!
lastName: String!
}Add Optional Arguments, Don't Add Required
# Original
type Query {
products: [Product!]!
}
# Evolution: add optional filter
type Query {
products(filter: ProductFilter): [Product!]!
}
# Never do this — breaks all existing callers
type Query {
products(filter: ProductFilter!): [Product!]!
}Use Input Object Extension for New Required Fields
When you need to add a required field to a mutation, use a new input type or version the mutation:
# v1
input CreateOrderInput {
items: [OrderItemInput!]!
shippingAddress: AddressInput!
}
# v2: Add required payment method — don't modify existing input
input CreateOrderInputV2 {
items: [OrderItemInput!]!
shippingAddress: AddressInput!
paymentMethodId: ID! # required in v2
}
type Mutation {
createOrder(input: CreateOrderInput!): Order! # deprecated but still works
createOrderV2(input: CreateOrderInputV2!): Order! # new
}Field Aliases for Type Changes
When a field's type must change, add the new field alongside the old one:
type Product {
price: Float! @deprecated(reason: "Use priceInCents for precision")
priceInCents: Int!
}Measuring Contract Coverage
Track which fields in your schema are covered by any consumer contract:
// scripts/coverage-report.js
const { buildSchema, parse, TypeInfo, visit, visitWithTypeInfo } = require('graphql');
const fs = require('fs');
const glob = require('glob');
function analyzeFieldCoverage(schema, operationFiles) {
const allFields = new Set();
const coveredFields = new Set();
// Collect all fields in schema
const typeMap = schema.getTypeMap();
for (const typeName of Object.keys(typeMap)) {
if (typeName.startsWith('__')) continue;
const type = typeMap[typeName];
if (type.getFields) {
for (const fieldName of Object.keys(type.getFields())) {
allFields.add(`${typeName}.${fieldName}`);
}
}
}
// Collect fields used in operations
const typeInfo = new TypeInfo(schema);
for (const file of operationFiles) {
const doc = parse(fs.readFileSync(file, 'utf8'));
visit(doc, visitWithTypeInfo(typeInfo, {
Field() {
const type = typeInfo.getParentType();
const field = typeInfo.getFieldDef();
if (type && field) {
coveredFields.add(`${type.name}.${field.name}`);
}
}
}));
}
const uncovered = [...allFields].filter(f => !coveredFields.has(f));
const coverage = (coveredFields.size / allFields.size * 100).toFixed(1);
console.log(`\nSchema coverage: ${coverage}%`);
console.log(`Covered fields: ${coveredFields.size} / ${allFields.size}`);
if (uncovered.length > 0) {
console.log('\nUncovered fields (no consumer contract):');
uncovered.forEach(f => console.log(` ${f}`));
}
}A field with no consumer contract is either unused (candidate for removal) or has a gap in your contract coverage. Either way, it's worth investigating before the field either accumulates technical debt or gets changed without anyone noticing.
Contract testing is ultimately about making implicit agreements explicit. Your GraphQL schema is a contract between your team and every client that depends on it. The tooling exists to make that contract machine-checkable — use it.