There are several resources advising best practices to design the schema for a GraphQL API, including the Best Practices section in graphql.org. These will lead us most of the way in the design of the schema, but often we may also bump into issues that are not generic, for which the best answer depends on our actual use case.

Here I will share several tiny issues I stumbled upon, and what decisions I deemed best to tackle them.

1. Enums: Should always use "PUBLISH", or can also use "publish"?

The enumeration type is useful to limit what values can be input, and to unambiguously declare what are the possible values for the output.

It is a recommended practice to print the enums in uppercase:

enum PostStatus {
  PUBLISH
  PENDING
  DRAFT
  TRASH
}

type Post {
  # Here the enum is an input to the field
  hasStatus(status: PostStatus!): Boolean!

  # Here the enum is an output from the field
  status: PostStatus!
}

The issue with GraphQL's specification for enums is that the enum represents both its name and its value. In other words, this is currently not possible:

enum PostStatus {
  PUBLISH: "publish"
  PENDING: "pending"
  DRAFT: "draft"
  TRASH: "trash"
}

(GraphQL servers such as Apollo may support this functionality, but that is their own, non-standardized implementation.)

This is not the case with programming languages, where the name of the constant and its value can differ. For instance, this PHP code declares the enum name in uppercase, and its value in lowercase:

enum PostStatus: string {
    case PUBLISH = 'publish';
    case PENDING = 'pending';
    case DRAFT = 'draft';
    case TRASH = 'trash';
}

If we are designing the GraphQL API and its underlying back-end from scratch, this may not be an issue, since we can design it to use uppercase on every layer of the stack. But if the GraphQL API retrieves data from an existing application, which has declared its enums not in uppercase, then we have a decision to make between these two possibilities:

  1. Should we have the GraphQL schema declare the enums in uppercase, as recommended?
  2. Or should we use the actual values used by the back-end system?

With the first approach, the GraphQL schema can be consistent and always use the same formatting for its elements. However, when the enum is an input, then we also need to have the resolver translate between the input "ENUM" from the interface into the "enum" value for the back-end service, adding extra logic and complexity.

When the enum is an output to the field, the translation of the value can actually become an issue, because now the client may also need to convert from the returned "ENUM" value to "enum", if this response is to be used to interact with the back-end service through a mean other than the GraphQL API.

In other words, what value should posts { status } receive? Should it be "PUBLISH" as declared by the GraphQL API, or should it be "publish" as is its underlying value in the back-end?

To be on the safe side, I prefer using the same value that is used in the back-end even if the schema is not as elegant as it could be. This makes sure that the client will always work, avoiding potential bugs from overlooking code. So I will use this enum type:

enum PostStatus {
  publish
  pending
  draft
  trash
}

But this is a decision that is not fixed. If I can thoroughly control the client code to the point of knowing the right value of the enum will always be used, then I will use uppercase instead.

2. Maximizing reusability when creating input fields for CRUD operations

When handling CRUD operations in the API, the inputs to the create and update mutations will most likely be the same, with the exception of a single input: the id of the object when doing an update (which is not needed for the create mutation since, of course, the object does not yet exist):

type MutationRoot {
  # Pass field args to create post
  createPost(title: String, content: String): Post
  
  # ID + same field args as above
  updatePost(id: ID!, title: String, content: String): Post
}

It is a good practice to use input objects to pass the input values to the field. Input objects help group elements together, giving it a more understandable structure and making it easier for the client to make the API calls. Then, instead of passing field arguments title and content, we can pass input PostInput instead:

input PostInput {
  title: String
  content: String
}

type MutationRoot {
  createPost(input: PostInput): Post
}

PostInput so far contains all the inputs needed for the create mutation, but it is missing the id field for the update mutation. To also provide for this field, there are 3 possibilities:

  1. Add id as a non-mandatory field to PostInput, and use the input in both createPost and updatePost:
input PostInput {
  id: ID
  title: String
  content: String
}

type MutationRoot {
  createPost(input: PostInput): Post
  updatePost(input: PostInput): Post
}
  1. Create 2 separate inputs, CreatePostInput and UpdatePostInput, where the id is only added to the latter one, as a mandatory field:
input CreatePostInput {
  title: String
  content: String
}

input UpdatePostInput {
  id: ID!
  title: String
  content: String
}

type MutationRoot {
  createPost(input: CreatePostInput): Post
  updatePost(input: UpdatePostInput): Post
}
  1. Have both fields use PostInput, and pass the id as an additional field argument to updatePost:
input PostInput {
  title: String
  content: String
}

type MutationRoot {
  createPost(input: PostInput): Post
  updatePost(id: ID!, input: PostInput): Post
}

From the 3 options, the first one is a no-go, because the id would be non-mandatory, so it is watering down the validation of the field arguments in the schema, and we would be technically able to execute a logically non-valid operation:

{
  # Not providing the ID is logically invalid and it should fail, but it will not
  updatePost(title: "...") {
    id
  }
}

So in essence we only have the 2nd and 3rd options to choose from. In my case, I like the 3rd option better, because it maximizes the reusability of the elements in the schema, since PostInput is used by 2 different fields and its input fields must then not be duplicated, and because just by looking at the schema we can see exactly what's the difference between the createPost and updatePost mutations: the id arg.

I'd prefer the 2nd option if the GraphQL spec permitted creating a type by extending from another type, so that we could declare UpdatePostInput as an extension of PostInput (as to avoid duplicating the definition for the common fields):

input PostInput {
  title: String
  content: String
}

input UpdatePostInput extends PostInput {
  id: ID!
}

But this is currently not permitted by the spec (there is an extend operation, but it modifies a type instead of creating a brand-new type from it).

3. Using mutation payloads to apply directives depending on the result of the operation

It is a good practice to return a payload object as a result of a mutation, and not directly the affected object, as to provide extra information related to the mutation, such as a message indicating why the mutation failed.

In other words, instead of doing this:

type MutationRoot {
  createPost(input: PostInput): Post
}

...we should do this:

type PostPayload {
  record: Post
  errors: [String]
}

type MutationRoot {
  createPost(input: PostInput): PostPayload!
}

(In this example we defined field errors of type String, but there are better ways to handle errors).

Using a payload object gives us the opportunity of another functionality: selectively applying directives based on the result of the operation.

For instance, let's say that when executing mutation addComment, I want to apply the following logic:

  • If successful, execute directive @regenerateSite to trigger a Netlify webhook to regenerate and redeploy the static site, printing the newly-added comment.
  • If unsuccessful, execute directive @sendNotificationToAdminByEmail to alert the admin of potentially unauthorized behavior on the site.

Payload objects allow us to architect a schema to cater for this challenge. The payload can retrieve a resultStatus with an object that will represent if the operation was successful or not:

directive @regenerateSite on FIELD
directive @sendNotificationToAdminByEmail on FIELD

interface ResultStatusInterface {
  message: String!
}

type SuccessResultStatus implements ResultStatusInterface {
  message: String!
}

type ErrorResultStatus implements ResultStatusInterface {
  message: String!
}

union ResultStatusUnion = SuccessResultStatus | ErrorResultStatus

type CommentPayload {
  record: Comment
  resultStatus: ResultStatusUnion!
}

type MutationRoot {
  addComment(comment: String!): CommentPayload
}

And then, based on the ResultStatus object type, we can apply one or another directive when executing the query:

mutation {
  addComment(comment: "...") {
    resultStatus {
      ...on SuccessResultStatus {
        message @regenerateSite
      }
      ...on ErrorResultStatus {
        message @sendNotificationToAdminByEmail
      }
    }
  }
}

4. Possibly use input unions even if not in the spec yet

The InputUnion is a proposal under consideration for the GraphQL spec, which has the goal of supporting polymorphic inputs, thus enabling mutation fields to receive inputs of different types. Input union types can simplify the schema by reducing the number of mutation fields in it.

For instance, let's say that the user can log into the service via different methods, such as by providing the username/password, or by passing a JWT. Currently, we'd need to have two different mutation fields, loginUserByWebsiteCredentials and loginUserByJWT. With the InputUnion, we can instead create a single loginUser mutation, which receives an input union:

input WebsiteCredentials {
  username: String!
  password: String!
}

input LoginInput @oneOf {
  websiteCredentials: WebsiteCredentials
  jwt: String
}

(The specifics of how the input union will be implemented is not defined yet; this example demonstrates one of the proposals, the @oneOf schema-type directive.)

The InputUnion basically defines that one and exactly one input must be provided, from all the named fields. In this case, input LoginInput must be provided either websiteCredentials or jwt, but not both.

The InputUnion proposal has not been approved yet, but we can expect this feature to be eventually added to the spec with a high level of confidence. Hence, we can already plan for it in the schema.

For instance, if currently we offer one way to log the user in, the loginUser mutation could directly receive that required information:

mutation {
  loginUser(
    usernameOrEmail: "admin",
    password: "pachonga"
  ) {
    id
    name
  }
}

But if we expect loginUser to support other methods in the future, then we can already have this mutation receive an input object credentials, expected to eventually become an input union object, and currently containing the only field being used (in this case, website):

mutation {
  loginUser(
    credentials: {
      website: {
        usernameOrEmail: "admin",
        password: "pachonga"
      },
    }
  ) {
    id
    name
  }
}

This query is more verbose than the previous one. However, when in the future we allow users to login using JWT, mutation loginUser will be able to support it without creating breaking changes, and we won't need to deprecate a field and ask users to switch to a new one.

Conclusion

In this article I described several such tiny decisions I had to take when designing my GraphQL API. When designing the GraphQL schema, we can use the best practices provided by the community to take us most of the way. But in addition we will quite likely bump into issues that are tiny enough, or specific enough to our use case, for which there is no specific response in advance on how to tackle them.