/ graphql

How Product Hunt Structure 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.

Screenshot-2019-06-30-at-18.07.19

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:

  1. those caused by system error
  2. 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 to onSubmit 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.