One missing field takes down every page that asks for it
Rename a field in Drupal. Deploy. Your CI is green, your types compile, your Next.js build succeeds. Every page that queries that field is now blank.
This has taken our front end down twice. Both times the deploy looked perfect.
GraphQL is all-or-nothing, and that is the whole problem
People assume a decoupled front end degrades gracefully: the CMS drops a field, the component that used it renders empty, everything else carries on. That is how REST behaves. It is not how GraphQL behaves.
GraphQL validates a query as a document, before it executes anything. If one selected field does not exist on the type, the server does not return partial data with a warning. It rejects the entire query:
Cannot query field "bogusFieldDoesNotExist" on type "NodeResource".
No data comes back at all. Not the field you removed — the whole response. Our node query is one document, roughly ten thousand characters, that drives every content page on the site. One stale selection in it does not degrade one component. It blanks or 500s every page that runs that query, at once, in production.
The blast radius is not proportional to the mistake. That is what makes it worth a gate rather than a code review habit.
Why nothing in the pipeline catches it
Walk the layers and you find each one is honestly reporting on something else:
- TypeScript checks the shape you claim the response has. The query is a template string.
tschas no opinion about its contents. - The Next.js build compiles code. It does not talk to Drupal.
- Unit tests mock the GraphQL client, so they assert on fixtures that were correct whenever they were written.
- Drupal's own tests pass, because from Drupal's side nothing is broken. Renaming a field is a perfectly valid thing to do.
Nobody is lying. The seam between the two systems is simply not represented anywhere, so nothing can check it. Additive drift — a new Drupal field the front end never picks up — is even quieter: it has no symptom at all, ever.
Make the seam an artifact
A contract you cannot diff is a contract you do not have. So we made the schema a file.
Drupal exports its GraphQL SDL and we commit the result in the front-end repo as graphql/schema.graphql — about 97KB, 136 types. It lives on the front end deliberately: the front end declares what it needs, and any CMS behind the seam has to satisfy it. That is the same posture that makes the contract portable to a different backend later.
Two things we did not build, because they already existed upstream in drupal/graphql:
drush graphql:dump graphql_compose_server
drush graphql:detect-breaking-changes graphql_compose_server contract.sdl
The first prints the SDL. The second parses a committed contract, runs the breaking-change finder, and exits non-zero on a breaking change while staying quiet on an additive one — exactly the distinction that matters. Searching the upstream tracker before writing code is cheaper than maintaining your own version of it forever.
One belief worth correcting, because it nearly stopped us: we assumed disable_introspection: true would block SDL export. It does not. That flag installs a query validation rule. Printing the schema walks the type map in-process and never executes a query. Introspection stays off for the public endpoint and the export still works.
The gate is a unit test
With the contract committed, the check is small. Build the schema from the file once, call each exported query function, capture the query text it would have sent, and validate it:
const errors = validate(schema, parse(document))
if (errors.length === 0) return
throw new Error(
"Query does not match the committed schema contract:\n" +
errors.map((e) => ` - ${e.message}`).join("\n")
)
We already had a test seam that mocks the GraphQL client, and npm test was already a blocking CI step. So the gate needed no new infrastructure, no new service, and no CI change. It is table-driven across every locale, menu, and bundle, so every reachable document gets validated rather than just the default path.
Raise the underlying messages verbatim. Our first version asserted expect(errors).toEqual([]) and failed with expected [Array(1)] to deeply equal [], which tells a future maintainer nothing. The library already produces the precise sentence — the field, the type, and often a suggestion. Do not throw it away for a tidier assertion.
What it buys
Breaking drift now fails red at pull-request time, with the field name in the error. Additive drift shows up as a diff on a committed file, which a human can read and decide about. The failure moved from production to a pull request, and from silent to specific.
The gate immediately started guarding queries written after it — including the draft-preview work that shipped the same week. That is the point. A check that only covers the code you were thinking about when you wrote it is a comment. A check that covers code someone else writes next month is a contract.