Nest, nest, nest
Aggressively nesting your GraphQL schema wherever possible is one of the best tools you have at your disposal for building GraphQL APIs that last. The simplest way to think about nesting conceptually is that it gives you more surface area to play with, and in general an API with more surface area will find it easier to support new functionality compared to an API that’s slimmer.
While you can usually add more surface area as needed there are some cases where doing so results in an ugly and hard-to-use API. The most extreme example I can think of would be a REST endpoint which returns a top-level array—the only way you can add new top-level fields here would be to return them via HTTP headers, which sounds downright horrible. There’s a reason why so many APIs decide to envelope their response payloads, and it’s because doing so bakes in high quality usable surface area for future evolution of the API.
Enveloping is—of course—an example of nesting in action, where we choose to return { data: T[] }
instead of T[]
. This pattern is generally applicable to a wide range of API architectures, but in this post we’re going to examine it in depth within the context of GraphQL. We’ll consider how applying nesting can pay off for a hypothetical “why this ingredient?” feature we’re building as part of a recipe generator.
“Why this ingredient?”
Let’s say we’re building a recipe generator. We discover one day that our recipe generator is including exotic ingredients in its output and our customers are getting discouraged because they don’t know how to use these “weird” ingredients. This problem threatens to increase our churn rate, so we need to roll out a solution.
Instead of tweaking the generator so it no longer produces recipes involving these niche ingredients we instead decide to build a feature called “why this ingredient?” When users hover their cursor over an ingredient, a popup will appear which explains why this ingredient was included in the recipe. These reasons end up getting rendered in bullet-point format.
If you had to design a hypothetical whyThisIngredient
GraphQL query to support this feature, what would it return? In my opinion, the only sensible option is something like the following:
type WhyThisIngredientReason { content: String!} type WhyThisIngredientPayload { reasons: [WhyThisIngredientReason!]! # ...}
It’s entirely possible to type that reasons
list as a [String!]!
, and that schema design would be perfectly serviceable for the initial design of our feature. From the consumer’s perspective, iterating over a list of strings and rendering them out is essentially the same thing as iterating over a list of objects and rendering out a property on those objects. If our GraphQL schemas should be informed by the demands of our client and both schema designs will satisfy our client requirements, then what’s the difference?
The difficult thing about designing good APIs is there are far more bad solutions than there are good ones. There are lots of API designs that will technically satisfy our client demands, but which are evolutionary dead ends. At the extreme end of the spectrum we could actually just type reasons
as a plain old String!
and mandate our consumer split reasons
on newline characters. That design is obviously bad, and I would contend that reasons: String!
is bad for exactly the same reasons that reasons: [String!]!
is bad.
So: why are both of these alternate designs bad, and why should we complicate things by introducing an intermediate WhyThisIngredientReason
object type?
The answer is simple. Sending a list of primitives—or one big opaque string—gives us a whole lot less flexibility down the line. One day our “why this ingredient?” feature is going to morph into version 2, and the amount of pain that transition causes us is directly proportional to how far we back ourselves into a corner with our version 1 design.
Imagine these hypothetical requirements we may need to support in version 2:
- What if we wanted to provide citation(s) for each of our reasons?
- What if we wanted to let users rate reasons up/down, as a data source for reinforcement learning?
You could support these new cases without breaking backwards compatibility by adding more top-level fields on WhyThisIngredientPayload
like so:
type WhyThisIngredientPayload { reasons: [String!]! citations: [URL!]! reasonSentiments: [WhyThisIngredientReasonSentiment]! # ...}
But this design sucks! What is the length of citations
? Are we guaranteed to always have the same number of citations as reasons? Is citations[2]
always the citation for reasons[2]
? What if I want to support multiple citations for a particular reason?
Here’s a curveball: what happens if we want to provide a label for our citations, or mark some citations as “verified”? We’re right back to where we started, and that list of primitive URL values is looking pretty limiting.
Does it sound at all reasonable for us to continue with this pattern of schema evolution, adding a top-level citationLabels
resolver?
How about this?
type WhyThisIngredientReasonCitation { label: String! url: URL! verified: Boolean} type WhyThisIngredientReasonFeedback { id: ID! comment: String sentiment: Sentiment!} type WhyThisIngredientReason { id: ID! citation: WhyThisIngredientReasonCitation content: String! feedback: WhyThisIngredientReasonFeedback} type WhyThisIngredientPayload { reasons: [WhyThisIngredientReason!]! # ...}
I’ve been able to add support for a bunch of features here, all without needing to break consumers of my original GraphQL schema. From here I can continue to evolve in a really natural way; if I decide, for instance, that I do want to support multiple citations for individual reasons then I can simply add a citations
(note the plural!) field to my “reason” type, and have citations[0]
function as an alias for citation
. I can keep that old citation
field around forever—or until my field-level instrumentation tells me no one is using it anymore.
I don’t think there are actually very many engineers out there who would seriously entertain the idea of my citationLabels
strawman. I have seen people add something analogous to the top-level citations
resolver before, but in all cases my teams have stopped short of adding the second set of top-level resolvers and instead refactored to return a list of reason objects.
Are breaking changes really all that bad?
Most of this blog’s readers are web developers. In this domain, we usually have high confidence that the majority of our users will end up on the latest version of our app pretty quickly. In that context, breaking changes aren’t as bad. Mobile developers and API companies, on the other hand, have no such guarantee. There are lots of ancient mobile app installations out there, and if you’re selling an API you have no control over the code your customers are writing. Breaking changes are bad news here.
Most of the time that happened in the form of a breaking change. You could keep the old list of reason strings around and make an additive change of reasonList: [WhyThisIngredientReason!]!
, but in practice very few people actually do this because keeping around reasons: [String!]!
is such an eyesore.
You can save yourself a whole lot of trouble here by following one of these two GraphQL schema design strategies:
- Just return objects everywhere. “Everywhere” here should be interpreted as meaning “anywhere it could be remotely reasonable to need additional metadata down the line.” In general, arrays of primitive values should be treated with a healthy dose of suspicion.
- Tighten up your naming. The main reason why this scenario tends to cause breakage is because the
reasons
resolver name represents seriously valuable API real estate. It’s a far more elegant name than something likereasonList
, and teaching consumers to usereasonList
instead of the legacyreasons
is an uphill battle. We could have avoided this battle through tighter naming—Hungarian notation à lareasonText: [String!]!
or evenreasonStrings: [String!]!
isn’t that bad, and it keepsreasons
available.
If you’re a long time reader then you may notice a similarity between the content of this post and an earlier one where I talked about including foreign keys in your GraphQL schema. There I complained about a languages
resolver in one of my work’s GraphQL schemas, which returned a list of language IDs. I suggested it would have made far more sense to expose that resolver as something like languages: [{ id: ID! }]!
instead, and that advice is actually the exact same advice I’m giving in this post.
Nest even if it adds complexity!
“Why this ingredient?” is actually pretty similar to something I built at work recently, and for that feature the introduction of our WhyThisIngredientReason
type might seem even less clear at first glance. Under the hood my whyThisIngredient
query caches its result by persisting its output into a PostgreSQL table, and in that PostgreSQL table I am literally just storing a string array of reasons
.
This means that in my service code I need to map from Array<string>
to Array<{ content: string }>
. It’s not a particularly complicated transform, but it’s still added code that I could have avoided had I typed my GraphQL schema as reasons: [String!]!
.
But like I’ve written previously, if your GraphQL schema matches up with your database schema 1:1 then you’ve probably made a horrible mistake. Database schema design is largely informed by the technical constraints of your underlying datastore; modeling our WhyThisIngredientReason
object is trivial in a document database like MongoDB and a bit harder in a relational database. This difference in difficulty will necessarily factor in to any schema design we decide on using these respective technologies.
When you leak those constraints up to your GraphQL layer, you are in effect leaking implementation details. Designing your graph architecture is a fantastic opportunity to decouple your consumers’ data requirements from the underlying storage engine, and leaning in to this is really important for longterm GraphQL success. Even really simple fields like id
can be abstracted away from the underlying storage; check out my post on implementing Global Object Identification for a more complete explanation of that.
So while I could have returned the reasons
array as-is from my PostgreSQL table and benefitted from an oh-so-slightly leaner backend implementation, it wouldn’t have been a good idea. It’s relatively simple for me to drastically reconfigure how my database schema looks because I only have one codebase interacting with that database. My GraphQL schema on the other hand has many consumers, which makes breaking changes significantly harder.
I’ll add a few extra lines of code to map my types any day of the week if it gives me a slightly more nested result.
Nest everything!
Nest, nest, nest, and then nest a little more. The deeper your object hierarchy goes, the less likely it is that you’ll need to either break your schema or eat an unfortunate naming decision.
One of the main reasons why you would choose to use GraphQL in the first place is because of its incredible ability to evolve over time and avoid the need for breaking changes. You can’t infinitely add fields to a REST endpoint’s response payload because doing so increasingly weighs down your API. The default behavior of REST is to eagerly provide the client with everything, even if they only really required a handful of fields.
This isn’t the case with GraphQL. You can have thousands of resolvers on a single type, and you only actually end up paying for what the client explicitly asks for.
A lot of focus gets placed on how GraphQL optimizes bandwidth consumption from the client’s perspective. This is a pretty cool perk as a lot of clients are working with slow networks, and there’s a real CO₂ cost to transmitting data over the wire.
But the client-side bandwidth story is a lot less influential relative to the server-side compute story. GraphQL’s query documents almost entirely decouples the growth of your server’s capabilities from the resources required to service end-user requests. That is what’s really powerful about GraphQL, and good schema design is all about leaning in to that.
Aliases are another example of how we can support almost arbitrarily complicated client demands without requiring an explosion in server resources. An assignments
connection field can be written to take filter parameters, and clients can then request that field multiple times under different aliases to satisfy their own unique data requirements. This kind of behavior is really hard and unwieldy to support via REST; generalized tooling for it just doesn’t exist.
Final thoughts
If you find yourself needing to break your GraphQL schema on a regular basis it’s worth taking a step back and asking yourself why. If Facebook can evolve their graph schema for years on end without a breaking change, then it stands to reason that your much smaller and far less complicated business should be able to as well.
Not making predictably bad schema design decisions in the first place is a good strategy for avoiding evolutionary dead ends. Lists of primitive values aren’t always a bad thing in GraphQL, but they should always be treated with suspicion. Boxing up those primitives with a simple object type container costs next to nothing and wins you a lot of flexibility down the line.
By the way this advice also applies to REST APIs. Back in November OpenAI added “JSON mode” to the GPT family of LLMs, and you opt in to that functionality via a cleverly designed response_format
parameter. It would have been easy for the engineers at OpenAI to have typed response_format
as a simple string union, but they have instead implemented it as a discriminated union type.
At the moment none of the response_format
union members actually take any fields other than the type
discriminator, but it’s easy to imagine that they might down the line. OpenAI can easily add a pretty-printing option to their json_object
response type in a typesafe fashion because of this API design choice.
In summary, here are the three things you should keep in mind when designing your GraphQL schemas:
- Be suspicious of primitive lists. Instead of
[String!]
, consider something like[{ value: String! }!]!
to give yourself more surface area. - Be suspicious of tightly coupled top-level fields. If you have a bunch of top-level fields with common prefixes such as
reasons
andreasonLabels
, then that’s usually a good sign that you can package these fields up into an object type. - Prefer nesting top-level operation results. Instead of returning
User
directly from auserById
query, consider returning something like{ user: User }
instead. This makes it easier for you to add a hypotheticaluserErrors
field to the response type down the line.