How Product Hunt Structures GraphQL Mutations
Mutations in GraphQL ... mutate data. ?
At the time of this writing, Product Hunt codebase and related projects have 222 mutations. All of our mutations have the same structure. This enables us not only to have a consistent system but to build a lot of tooling around it to make our developer's life easier.
Here the Product Hunt guide on structuring GraphQL mutations:
Naming
There are only two hard things in Computer Science: cache invalidation and naming things
-- Phil Karlton
We have decided to name our mutations in the following pattern:
[module][object][action]
Here are some examples:
CommentCreate
CollectionPostAdd
PostVoteCreate
PostVoteDestroy
PostSubmissionCreate
ShipContactCreate
ShipContactDestroy
The reason to follow this approach is consistency and easier grouping. Autocompleting mutations names is even easier because you filter from module to object to the action.
Mutation shape
This is how a typical mutation looks like:
mutation CollectionPostAdd($input: CollectionPostAddInput!) {
response: collectionPostAdd(input: $input) {
node {
id
...SomeFragment
}
errors {
name
messages
}
}
}
It has the following elements:
- it is Relay compatible
- the result object is always named
node
- it always has
error
array for user input validations - in frontend we alias the mutation to “response.”
Let look at each one of those elementals:
Relay compatibility
We are using Apollo in our frontend. However, we try to have Relay comparable scheme, just in case this situation changes.
In order for a mutation to be Relay compatible, it is required to have a field named clientMutationId
and accepted all of its arguments as inputs
variable. inputs
contains all arguments.
We tend to use clientMutationId
when we don’t care about the mutation result. For example - TrackEventCreate
.
inputs
is more interesting. It is an input type, containing all values needed for the mutation.
It makes it very easy to work with Forms. Because we have a single argument object to hold the form state, we can just pass this object to the mutation. When we add/remove arguments from a given mutation, we don't have to change the mutation call at all.
I really like this concept.
Node field
We noticed that most mutations operate on a single object. Because Relay uses the term node
a lot (example - connections). We decide to name the object returned from the mutation - node
. This makes it easier for the frontend to handle it since it knows what to expect and where to look for it. Plus backend mutation can just return an object, making defining mutations in the backend easier.
In some rare occasions, we might need a second object to be returned from mutation. We support this.
Errors field
There are a lot of debates on how to handle mutation errors. Should the GraphQL error system be used, or should we return an array of error objects?
I prefer returning error objects. I split the errors into two buckets based on their cause:
- those caused by system error
- those caused by a user input error
In Product Hunt, we handle system errors with GraphQL system error, and we handle user input errors with error objects. Because of this, all our mutations have error
fields, which return an array of Error
objects:
type Error {
name: String!
messages: [String!]!
}
Our BaseMutation
knows how to return it and our Form.Mutation
knows how to map the errors to its input fields.
We use the name base
for errors which can’t be attached to a particular field. Example: "you posted too many posts for today".
I have written about this before ? here.
Response alias
We alias the mutation field name to response
, so we can simplify our frontend code:
const response = await mutation({ variables: $input });
responseNode(response)
responseErrors(resoonse)
Notice that this code applies to every mutation we have.
Backend tooling
BaseMutation
If you are working by schema first, enforcing those rules will be quite painful.
In Product Hunt, we use the revolver first approach to GraphQL development. We have a lot of built-in tools on top of GraphQL ruby gem.
One of those tools is BaseMutation
. Every mutation in our system uses it.
Here is an example:
module Graph::Mutations
class CollectionPostAdd < BaseMutation
argument_record :collection, Collection, authorize: :edit
argument_record :post, Post
argument :description, String, required: false
returns Graph::Types::CollectionPostType
def perform(collection:, post:, description: nil)
Collections.collect(
current_user: current_user,
collection: collection,
post: post,
description: description
)
end
end
end
- it generates consistent mutations.
- it can fetch records
- it handles authorization
- it knows how to map returns to
node
field - it knows how to handle raised errors from Ruby on Rails, validation, authorization and similar
The developer just has to think about:
- what is your input
- what are you returning
- what are the authorization rules
- implement
All the mechanics are handled by this class.
This is how we define mutations:
module Graph::Types
class MutationType < Types::BaseObject
# we have this small helper to reduce the noise when defining mutations
def self.mutation_field(mutation)
field mutation.name.demodulize.underscore, mutation: mutation
end
mutation_field Graph::Mutations::CommentCreate
mutation_field Graph::Mutations::CollectionPostAdd
mutation_field Graph::Mutations::PostVoteCreate
mutation_field Graph::Mutations::PostVoteDestroy
mutation_field Graph::Mutations::PostSubmissionCreate
mutation_field Graph::Mutations::ShipContactCreate
mutation_field Graph::Mutations::ShipContactDestroy
# ...
end
end
Frontend tooling
Having all wiring in the backend makes it very easy to build tooling for frontend. I already mentioned responseNode
and responseErrors
. We have them, but in reality, we use Form.Mutation
and MuttationButton
more.
Form.Mutation
I have written and spoken about this before. Here is a link to a post about it, and here is a link to a presentation about forms in general (here is a recording)
<Form.Mutation onSubmit={onComplete}>
<Form.Field name="title" />
<Form.Field name="email" control="email" />
<Form.Field name="description" control="textarea" />
<Form.Field name="length" control="select" options={LENGTH_OPTIONS} />
<Form.Field name="level" control="radioGroup" options={LEVEL_OPTIONS} />
<Form.Field name="speakers" control={SpeakersInput} />
<Form.Submit />
</Form.Mutation>
This form handles:
- provides consistent UI for forms
- map fields and arguments
- knows how to pass inputs to mutation and interpret results
- protects against double submit
- handles validation errors and map them to fields
- handles the successful submission and passes
node
toonSubmit
handler
MutationButton
The second main way mutations are triggered by buttons. We have a React component name MutationButton
:
function LikeButton({ post, onLike }) {
const mutation = post.isLiked ? DESTROY_MUTATION : CREATE_MUTATION;
const optimistic = post.isLiked ? optimisticDestroy : optimisticCreate;
return (
<MutationButton
requireLogin={true}
mutation={mutation}
input={{ postId: post.id }}
optimisticResponse={optimistic(post)}
onMutate={onLike}
active={post.isLiked}
icon={<Like Icon />
label={post.likesCount}
/>
);
}
This button handles:
- triggering the mutation with right arguments
- protects against double click
- handle optimistic updates
- handles clicks from not logged in users
- knows how to interpret the result of the mutations
Conclusion
Having all those structure and tooling around mutations helps the developers to focus on business logic and not worry about mechanics. This is a big win in my book.