15 May, 20248 minute read

My take on GraphQL naming conventions

Design consistency is important, because it frees up developer brainpower for important work. Naming standards help avoid internal bikeshedding, and makes it easier for external developers to get up to speed on the API. Almost every company has its own internal guidelines which describe the internal coding style they use.

There are a large number of GraphQL style guides out there on the web. Apollo’s one immediately comes to mind, and even members of the GraphQL working group have offered their two cents on the matter. These resources are great starting points, and I think you should go read a few different naming guides to feel out how different people see this topic.

Personally, my contention with existing resources is that they tend to offer up naming suggestions without any justification for why their idea makes sense from a developer experience perspective.

An expert coffee grader is able to break down and explain why a particular cup is quality. This isn’t an innate skill; graders develop their taste over an extended period of time. I think the same is true for developer experience.

We should be able to break down why an API feels good to use, and why a particular design is more or less extensible than another. I’m not convinced when someone merely tells me to “do X.” I want to hear a concrete rationale, because I’m concerned with the long-term maintainability of my schema. I’m happy to trade off some aesthetics or purity in the name of developer experience—you’ll see that bias bleed through when I talk about naming mutation fields (INTERNAL).

So the big point of difference between this post and other GraphQL naming guides is that I’m going to try to tie my recommendations back to real-world use cases. In particular I’m going to talk about how different naming strategies impact your ability to evolve your schema over time, because

This post is particularly exciting, because I’ve built a table of contents system and this is the first post it’s being used on. Feel free to jump straight to the section that interests you most.

Disclaimer

For example, I’m going to talk about how to name and structure your mutation’s result types. My guidance here is two-legged:

  1. You should suffix these types with Result. For example: PostCreateResult.
  2. You should envelope the object under operation. So, PostCreateResult.post: Post rather than returning a Post at the top-level of the result.

The first leg here is prescriptive insofar as I think you should suffix these types with something, and then be consistent with it. But you don’t need to use Result suffix; I’ve seen Payload, Response, and other suffixes all used in the wild and they’re all perfectly reasonable choices. I personally prefer Result when designing my own schemas, the point is to pick something and be consistent with it.

The second leg is prescriptive, because following this advice yields a schema that is strictly better when it comes to forwards evolution. Enveloping the created Post object gives us far more flexibility compared to simply returning the Post.

Keep this in mind this distinction when reading through the guidelines in the next section.

Guidelines

Query fields

Query fields should avoid verbs.

Click to copy
type Query {  # ❌  getPosts: [Post!]!  # ❌  listPosts: [Post!]!    # ✅  posts: [Post!]!}

Why? Because it creates symmetry between the root-level Query fields, and nested fields on your other schema types:

Click to copy
# ❌query ListBlogPosts {  listPosts {    id    # this is an eyesore    listComments {      id      content    }    # this is inconsistent    comments {      id      content    }  }] # ✅query ListBlogPosts {  posts {    id    comments {      id      content    }  }}

Mutation fields

Mutation fields should be named nounVerb and not verbNoun.

Click to copy
# ❌type Mutation {  createPost(    input: CreatePostInput!  ): CreatePostResult!} # ✅type Mutation {  postCreate(    input: PostCreateInput!  ): PostCreateResult!}

Why? Because consumers will almost always know what kind of object they are working with, but might not always know what actions are available. In a RESTful API operations are naturally namespaced by object by the URL path or by the client library itself (consider: stripe.paymentIntents.create, not stripe.createPaymentIntent). It’s theoretically possible to do this kind of namespacing in GraphQL, too:

Click to copy
# ❌type PostMutation {  create(    input: CreatePostInput!  ): CreatePostResult!} type Mutation {  posts: PostMutation!} mutation CreateAPost {  posts {    create(input: ...) {      # ...    }  }}

But in practice this causes issues because the GraphQL spec has special-cased behavior for top-level fields on the Mutation type. While other selection sets are free to run in parallel, top-level mutations are guaranteed to run sequentially. When you group mutations like this you are opting out of this special-cased behavior and it can introduce subtle bugs to your system.

Why might a consumer not know what actions are available? In GraphQL, we want to name our API operations according to their semantic meaning rather than in terms of their technical implementation details. RESTful APIs tend to do the opposite, which means there’s a rough standardization on verbs supported by those APIs (typically: “create”, “update”, “get”, “list”, “delete”).

In the case of our blog post—all of these verb choices could be legitimate depending on what our business requirements are:

Click to copy
type Mutation {  # Maybe this moves the post to an "archives" page  # and hides it from the home page?  postArchive(...): ...  # Perhaps this is a hard delete  postDelete(...): ...  # Hide from home page, but permalink still works?  postHide(...): ...  # This could be a soft delete?  postUnpublish(...): ...}

How is a consumer to know that your platform prefers archiving posts instead of deleting them? They can’t. In the name of improved schema discoverability we are therefore forced into using alphabetization to group related operations together, hence nounVerb is preferred.

List fields

List fields should always be pluralized.

Click to copy
# ❌type Post {  comment: [Comment!]!} # ✅type Post {  comments: [Comment!]!}

Why? Because it’s common to start out thinking something should be singular, only to later evolve the schema to take a list.

Say we started building out blog a year ago. At the time, we just wanted the ability to display a nice feature image from Unsplash at the top of our posts so we specified a single image: Image field on our Post type. Since then, we’ve now decided that this is a bit too limiting and we want to add support for displaying a carousel of feature images.

Before making any changes to support carousels, our schema looks like this:

Click to copy
type Post {  "Optional feature image"  image: Image}

Changing image to a list type would break existing consumers, which we want to avoid. The natural evolution from here is to introduce a new field named images typed like so:

Click to copy
type Post {  image: Image @deprecated(reason: "Use images[0] instead")  images: [Image!]!}

This works pretty well. You might even opt not to deprecate the image field, and keep it around long-term as an application of the collection lookup pattern.

This also works in reverse; if we start out with a field images: [Image!]! then it’s natural for us to add an image: Image field later on down the line.

When you break pluralization rules, things get messy. Imagine we started out with a field image: [Image!]! and then decided to add the singular field. There’s no good API surface area available for us to use, which means we need to invent a creative field name. You generally want to avoid being put in this position, because a creative name is almost always going to be unintuitive to developers trying to use the schema.

Enum types

Always suffix enum names with Type.

Click to copy
# ❌enum PostVisibility {  PUBLIC  UNLINKED} # ✅enum PostVisibilityType {  PUBLIC  UNLINKED}

Why? Because down the line you might want to introduce an actual PostVisibility resource to your schema. For instance:

Click to copy
type PostVisibility {  state: PostVisibilityType!  updatedAt: DateTime!  updatedBy: User!} type Post {  # ...  visibility: PostVisibility!}

The Type suffix doesn’t tend to add much friction to the developer experience. Most usage of your schema will come from reading fields (in which case no types need to be specified by the consumer), or from populating an input argument (in which case the consumer only needs to specify the input object’s type). For certain GraphQL frameworks, this suffix also makes defining your schema a bit more ergonomic. Consider the following example using Nexus:

Click to copy
// services/some-feature.tsexport enum PostVisibility {  PUBLIC = 'PUBLIC',  UNLINKED = 'UNLINKED',} // graphql/types.tsimport { enumType } from 'nexus'; import { PostVisibility } from '@/services/some-feature'; // we've avoided a name conflict!export const PostVisibilityType = enumType({  // ...});

Input object types

Always suffix input object type names with Input.

Click to copy
# ❌input PostCreate {  title: String!  content: String!} # ✅input PostCreateInput {  title: String!  content: String!}

Why? GraphQL doesn’t have support for namespaces, which means every single one of your schema’s types need to co-exist with each other inside a shared global namespace. Presumably your input types will be named similarly to your object types, which means it is very likely you’ll run into a name collision if you don’t suffix with something:

Click to copy
input Post {  title: String!  content: String!} type Mutation {  # I want to return the created Post here :/  postCreate(input: Post!): ...}

You could work around this by using PostCreate as the name, but then a common pattern in GraphQL is to reuse input types for both the create and update mutations—leaving you in a weird spot.

Click to copy
# calling this `PostCreate` would be weird# if `postUpdate` can also take it...input PostInput {  title: String  content: String} type Mutation {  postCreate(input: PostInput!): Post  postUpdate(id: ID!, input: PostInput!): Post}

If you don’t suffix from the start, you’ll likely end up needing to down the line anyway. And in that case you introduce inconsistency in naming conventions across your input types. You are better off suffixing from day one.

Mutation return types

Result types from a mutation should always be suffixed with Result, and the object being operated on should be returned under an envelope.

Click to copy
# ❌type Mutation {  postCreate(    input: PostCreateInput!  ): Post} # ✅type PostCreateResult {  post: Post} type Mutation {  postCreate(    input: PostCreateInput!  ): PostCreateResult!}

Why? Because nesting like this yields a more flexible path for future evolution. Say we wanted the ability to return typed validation errors from postCreate; it’s impossible for us to support this if we are returning a raw Post object and trivially easy to do when we envelope:

Click to copy
enum PostErrorCodeType {  EMPTY_TITLE  EMPTY_CONTENT  # ...} type PostError {  code: PostErrorCodeType!  message: String!} type PostCreateResult {  "Null if post creation failed, look at `errors`"  post: Post   """  List of reasons why this post could not be created.  This is empty if the mutation completed successfully.  """  errors: [PostError!]!} type Mutation {  postCreate(    input: PostCreateInput!  ): PostCreateResult!}

Casing style

So long as it’s consistent throughout your API, I don’t think this matters too much. The “conventional” casing style used by most GraphQL APIs looks like this:

The only point here that I’m particularly opinionated on is the fourth one. Global object identification special-cases the id field on an interface named Node, which means if you want to be consistent you can’t have other abbreviated fields in PascalCase or ALL_CAPS.

Click to copy
# ❌type Post implements Node {  id: ID!  # would be consistent with `ID: ID!`  contentHTML: HTML!  # would be consistent with `id: Id!`  contentHtml: Html!} # ✅type Post implements Node {  id: ID!  contentHtml: HTML!}

You might think that type names being pascal cased is also worth being opinionated about, given that the basic Mutation, Subscription, and Query types are all in pascal case. True, but it’s also possible to opt out of these default type names and provide your own:

Click to copy
schema {  query: my_query} type my_query {  post(id: ID!): post_type}

This is not particularly idiomatic, but some teams may prefer this if their tech stack generally uses snake cased names. Nothing wrong with this decision, so long as the convention used throughout your schema is consistent.

I will note, though, that the casing recommendations I’ve made in this section are fairly idiomatic across GraphQL APIs. In cases where you are exporting a public API—like we do at Rye—you probably want to align with these naming conventions regardless of your tech stack, solely so your API ‘feels’ the same as other GraphQL APIs that your customers might be using.

Don't want to miss out on new posts?

Join 100+ fellow engineers who subscribe for software insights, technical deep-dives, and valuable advice.

Get in touch 👋

If you're working on an innovative web or AI software product, then I'd love to hear about it. If we both see value in working together, we can move forward. And if not—we both had a nice chat and have a new connection.
Send me an email at hello@sophiabits.com