Graphql Is Not Expressive
04 Sep 2024 - Ben
It has only two categories of inquiry: Query and Mutation, with Resolvers knowing how to construct the return value. It’s Query field lets you choose what results you want, that’s great. But there’s no way to relate a Mutation to another Type. For instance, a REST endpoint of conversation/:conversationId/chatEvent/create
easily tells the reader that we’re creating a chat event in relation to a specific conversation. The body of the requestcan then all be related to the creation, instead of being split between querying and creation.
In GraphQL, a Mutation has no “related” or “subset of” expression. Instead, a similar mutation to our above endpoint would look like:
type Mutation {
createChatEvent(
conversationId: ID!
input: ChatEventBodyInput!
): ChatEvent
}
type ChatEventBodyInput {
body: String
metadata: [String]
}
A naive mutation might even lump all the args together in a flat list, instead of nesting them like my above example. But in either version, the conversation id is painfully front and center, making the arguments for creating a chat event a little muddier, a little less obvious.
Related mutations could be called anything. Resources have no graph. You can’t show relatedness, except through enforced naming conventions. All this feels antithetical to GraphQL’s intended purpose.
Another example, the Nested mutation
type Mutation {
conversation(
id: ID!
createChatEvent: ChatEventInput
): Conversation
}
This mutation example does nest and relate like our REST example. It adds the possible mutations as arguments under a single Mutation operation. But at least in NestJS, there’s no support for the idiom. It’d all filter through a single Mutation handler and we’d have to dispatch it ourselves, very unlike Resolvers. Then we’d need the resolvers to traverse the graph back to the item we just created! I’ve never seen this version in the wild, but very quickly it has already become too much work. If you want a REST-like system, use RESTful endpoints.
One last example, the Resolver Mutation
type Mutation {
conversation(id: ID!): ConversationMutations
}
type ConversationMutations {
chatEvent(id: ID!): ChatEventMutation
}
type ChatEventMutation {
create(input: ChatEventInput!): ChatEvent
}
This version is performing operations depending on which fields on our Mutation are resolved. It uses the return value to perform the actual mutation. This is much more expressive, it looks a lot like our REST pattern. It would have great support in NestJS. But it’s not a pattern the graphql docs look at, and it would likely defeat some of the ordering guaratees. For those reasons, I conclude that this isn’t really a pattern graphql supports.
Conclusion
Graphql favors the return value so much more than the Query/Mutation interface that it creates lopsided APIs. “Thinking in graphs” works for the return value, which is very expressive. But the input and arguments are not even close. Is this a fair trade off for REST endpoints, where the input seems more expressive but the output more rigid? I don’t think so, because it’s easy to create expressive endpoints. When you develop both sides, you control the output.