Production Ready GraphQL

Part 3: Schema Design

The most important take-away in defining a solid schema is consistency. You need to consider how your schema can change over time and choose a pattern that allows constant updates without introducing breaking changes. Users of your API should be able to make assumptions based on previous usage.

Unlike a typical Rest API it is not common to version a GraphQL API. Instead, GraphQL APIs are designed to be flexible and evolve over time.

Queries and Mutations

Looking at a typical "User" type, here's a pattern I like to follow that I've found scales well.

type User {
  id: ID!
  name: String!
  age: Int!
}

type GetUsersResponse {
  records: [User!]!
}

input WhereUserInput {
  id: ID
}

input WhereUsersInput {
  name: String
  age: Int
}

input CreateUserInput {
  name: String!
  age: Int!
}

input UpdateUserInput {
  name: String
  age: Int
}

type Query {
  getUser(where: WhereUserInput!): User!
  getUsers(where: WhereUsersInput): GetUsersResponse!
}

type Mutation {
  createUser(input: CreateUserInput!): User!
  updateUser(input: UpdateUserInput!): User!
}

Single Input

You'll notice all mutations take a single input. This makes it far easier to send queries and generate types because the base variables never change. Consumers of the API do not need to update client code every time you introduce new input.

With this we can write a query with a single input and we never need to change it regardless of how many variables we wish to provide.

mutation createUser($input: CreateUserInput!) {
  createUser(input: $input) {
    id
    name
    age
  }
}
const res = graphql.request(query, {
  input: {
    name: "John",
    age: 30,
  },
});

Lists and Pagination

Lists are returned within a records field rather than on the root like getUsers(where: WhereUsersInput): [Users!]!.

This means you can introduce other functionality such as meta data for pagination without causing breaking changes. The data is always in the same place regardless of other meta provided.

Here we could introduce pagination to the above schema without causing any breaking changes.

type PaginationMeta {
  total: Int!
  perPage: Int!
  currentPage: Int!
  totalPages: Int!
}

input PaginationInput {
  page: Int
  perPage: Int
}

type Query {
  getUsers(
    where: WhereUsersInput
    pagination: PaginationInput
  ): GetUsersResponse!
}

Relationships

When returning related data always return types rather than internal details such as ID's.

This ensures your schema does not return duplicated information or expose internals about how you choose to define relationships.

Since these things can change over time it's important to decouple your schema from your back-end implementation to allow the two to change independently.

type Post {
  id: ID!
  title: String
  user: User!
  # This is an anti-pattern, you are exposing how the backend
  # defines the relationship and duplicating data which
  # is already available on the User type.
  userId: String!
}

type User {
  id: ID!
  name: String!
  age: Int!
  posts: [Post!]!
}

Tooling

It can become tedious to define all the boilerplate. There are tools available to help you do this with approaches like in-code schemas and schema generators.

GraphQL Relay - Allows the easy creation of Relay-compliant servers. https://www.npmjs.com/package/graphql-relay

Hasura - Generate an instant GraphQL API in-front of your database. https://hasura.io/

My main recommendation would be to write a basic boilerplate for a new type and use that when adding new types to your schema. You could even automate this with a basic generator.