Six months into our first serious GraphQL project, a few schema design decisions we made early on have needed real rework, and it is worth writing down what we would tell ourselves at the start, since most of what went wrong was avoidable in hindsight and none of it was really about GraphQL itself being difficult.
Why early schema mistakes cost more than they would in REST
A REST API's individual endpoints can often be changed or added incrementally without much ceremony, since each endpoint is relatively independent of the others. A GraphQL schema is more like a single, interconnected graph where types reference each other, and a poor early decision about how a type is named or shaped tends to ripple outward into every query that touches it, making a schema mistake discovered six months in meaningfully more expensive to fix than the equivalent REST mistake would have been at the same point.
What we would tell ourselves at the start
- Name types and fields for what they mean to the client, not for how they happen to be stored in the database — we mirrored our SQL table names too literally at first and regretted it the moment the database schema needed to change for reasons that had nothing to do with the GraphQL API's own, separate concerns
- Use enums for any field with a small fixed set of values instead of a loose string, since it gives the client real compile-time-ish safety and makes invalid values a schema violation caught immediately rather than a runtime bug discovered later
- Paginate any list field from day one, even if it feels unnecessary at launch — retrofitting pagination onto a field that clients are already querying without it is a breaking change, since existing clients expecting a flat array will not know what to do with a paginated connection type introduced later
- Keep mutations narrow and purpose-specific rather than one generic "update" mutation per type, since narrow mutations are far easier to reason about and to authorize correctly, and a generic update mutation tends to accumulate special-case logic for every field it touches over time
The specific incident that taught us the pagination lesson
Our product listing query shipped without pagination, on the reasonable-sounding assumption that no client would realistically need more than the first page of results anyway. Six months later, a new internal reporting tool genuinely needed to page through the full catalog, and adding pagination at that point meant either breaking every existing consumer of the original field or introducing an awkward second field alongside the first, which is exactly the kind of schema clutter that accumulates when a decision that should have been made once at the start instead gets made twice, under worse conditions the second time.
Deprecation, not deletion
One pattern we adopted partway through this project that we would now recommend from day one: mark a field deprecated with a clear migration note rather than deleting it outright the moment a better alternative exists. GraphQL's built-in `@deprecated` directive surfaces a deprecation warning directly in tooling that introspects the schema, giving consuming teams real, actionable visibility into what they should migrate away from before it disappears entirely, rather than a breaking change landing with no warning.
Involving consuming teams earlier in schema design
Part of what went wrong here was designing the schema somewhat in isolation from the teams that would eventually consume it, rather than involving them in early review. We have since started running a short schema design review with every consuming team — mobile, web, internal tooling — before a new type or major field ships, specifically to catch a naming or shape decision that looks fine in isolation but creates friction for a consumer's actual use case, before it is embedded in a schema that is genuinely harder to change once real queries depend on it.
The general lesson
None of this is unique to GraphQL, honestly — most of it is API design discipline that REST APIs also benefit from. GraphQL's flexibility just makes it easier to get away with skipping this discipline early, right up until it stops being easy, since the interconnected nature of a graph schema means the cost of an undisciplined early decision compounds in a way a REST API's more independent endpoints tend not to.
How we handle breaking changes we cannot avoid entirely
Even with careful deprecation practices, an occasional genuinely breaking change is sometimes unavoidable — a field whose underlying meaning was subtly wrong from the start, for instance, where continuing to support the old, incorrect behavior indefinitely would be worse than a clean break. For these rare cases, we now require an explicit migration guide published alongside the schema change, plus a minimum notice period agreed with every known consuming team, treating a genuine breaking change as a coordinated event rather than something that simply ships in the next deploy the way a purely additive change safely can.
Building internal tooling to catch schema smells automatically
Beyond the manual review process, we built a small internal linter that runs against schema changes in continuous integration, flagging patterns we have learned to distrust — a new field with a name that too closely mirrors a database column name rather than a client-facing concept, a new list field with no pagination arguments, a new mutation with an overly generic name like `update` or `save`. None of these automated checks are perfect, and a few flagged patterns turn out to be genuinely fine on closer human review, but catching the majority of these patterns automatically, before a human reviewer even needs to notice them, has measurably reduced how often the same class of mistake reaches production compared to relying on manual review discipline alone.
A retrospective on the original mistakes, six months on
Looking back at the specific naming mistake that started this whole retrospective, the actual migration to friendlier, client-facing names took about a day of focused work plus a two-week deprecation window for existing consumers to move over, which was a meaningfully smaller cost than we feared when we first identified the problem. That experience taught us something important beyond the specific lesson about naming: a schema problem identified and fixed early, even a few months in, is still considerably cheaper to fix than one identified a year or two later, once considerably more of an application's logic has come to depend on the original, flawed shape.