Schema design, evolution and tooling

Additive change versus versioning, safe deprecation, schema checks in CI, code generation, persisted queries and linting.

Changing a schema without breaking clients

type Book {
  id: ID!
  title: String!
  publishedOn: Date @deprecated(reason: "Use publishedAt, which includes a timezone.")
  publishedAt: DateTime
}

type Query {
  books(first: Int): BookConnection!
  allBooks: [Book!]! @deprecated(reason: "Unbounded. Use books with pagination.")
}
  • Adding a nullable field, an optional argument or a new enum value is safe. Removing a field, tightening nullability or adding a required argument is not.
  • Deprecate first, measure usage, then remove. @deprecated is machine-readable, so tooling can report remaining callers.
  • A rename is two changes: add the new field, keep the old one resolving the same data, migrate clients, then delete.
  • Enum values are breaking to remove but safe to add - unless a client switches exhaustively and has no default branch.
⚠️
Versioning the endpoint (/graphql/v2) throws away the main advantage of GraphQL. The schema is the contract; evolve it additively and keep one endpoint. Reserve a URL change for a genuine architectural break.

Code generation and CI checks

# codegen.yml
schema: http://localhost:4000/graphql
documents: "src/**/*.graphql"
generates:
  src/generated/types.ts:
    plugins:
      - typescript
      - typescript-operations
      - typescript-react-apollo
# in CI: fail the build on a breaking change
npx graphql-inspector diff origin/main:schema.graphql schema.graphql --rule suppressRemovalOfDeprecatedField
npx graphql-inspector validate "src/**/*.graphql" schema.graphql
npx eslint src --ext .ts
ToolJobRuns
graphql-inspectorDiff two schemas, flag breaking changesCI on every pull request
graphql-code-generatorTypes and hooks from schema plus documentsBuild step
eslint-plugin-graphqlValidate queries inside source filesEditor and lint
Persisted query registryReject unknown documentsRuntime, production

Generate types from the deployed schema, not from a copy in the repository, or the drift you were trying to prevent comes back. Publish the schema as a build artefact so the diff is reviewable.

Persisted queries and performance

// automatic persisted queries: client sends a hash, server asks for the body on miss
const server = new ApolloServer({
  typeDefs,
  resolvers,
  persistedQueries: {
    cache: new RedisCache({ host: process.env.REDIS_HOST }),
    ttl: 900,
  },
});

// for a mobile app, ship an allowlist at build time and reject anything else
const allowed = new Set(JSON.parse(fs.readFileSync("queries.json", "utf8")));
  • Persisted queries shrink every request to a hash, which matters most on mobile networks.
  • An allowlist is also a security control: the server executes only documents your own client shipped, so an attacker cannot invent expensive queries.
  • The cache key must include the operation hash and the variables separately, since different variables produce different responses.
  • Bust the persisted cache on deploy when the schema changes, or old hashes will map to stale documents.

FAQ

How long should a deprecated field stay?
Until usage reaches zero, and you should measure rather than guess. Instrument the field resolution to record callers, publish the deprecation in the schema and the changelog, then set a removal date and hold to it.
Do I need a schema registry?
When several teams publish to one graph and clients must be protected from breaking changes, yes. For a single team and one client, a CI diff check against the main branch gets most of the benefit for much less machinery.

Writing queries: variables, fragments and directives Testing GraphQL APIs

Last refreshed 2026-09-18.