GraphQL Schema-first Design: Best Practices and Development Recommendations

Schema-first development and design is a crucial concept that is gaining traction in GraphQL. When starting out, it's easy wonder if you are designing your GraphQL schema the right way (is there even a right way?), or if your schema design will use a few tweaks and improvements or a need huge refactor later. Should you write many small mutations or a few huge one? How should you validate? Should you even use an ORM or Prisma or talk straight to the database...

...well. Here are the most common best practices and recommendations.

I have brought together what is the probably the most comprehensive collection of the GraphQL schema design best practices on the web to help you along.

Heck, even GraphQL carries a caveat:

The articles in this section should not be taken as gospel, and in some cases may rightfully be ignored in favor of some other approach.

– GraphQL, "GraphQL Best Practices"

Go figure.

Designing GraphQL Schema

The first step in building a GraphQL API is designing the schema. This is where your implementations of the GraphQL specification starts.

A GraphQL schema is defined (and designed) by:

  1. The types and directives it supports; and,
  2. The root operation types for each kind of operation (i.e query, mutation and subscription) it supports.

The root operation types defined in the schema determine the place in the type system where those operations begin.

A query root operation MUST be provided and it MUST be an Object type. The mutation and subscription root operations are optional but if provided they must also be Object types.

The schema you build on your server is what defines your GraphQL API. The GraphQL schema must be internally valid. Let's see some schema validation rules.

GraphQL Internal Schema Design Validation Rules

According to the GraphQL Spec, for a GraphQL schema design to be internally valid:

  1. All types in the schema must have unique names. This avoids naming conflicts. A type cannot have a name similar to a built-in type including Scalars and Instrospection types.
  2. All directives in the schema must have unique names.
  3. All types and directives in the schema MUST not have a name that begins with two underscores (i.e. __). This is reserved for the introspection system.

What are Default Root Operation Types

In the type system definition language, a document MUST include one schema definition as follows:

        
schema {
  query: MyQueryRootType
  mutation: MyMutationRootType
}

type MyQueryRootType {
  someField: String
}

type MyMutationRootType {
  setSomeField(to: String): String
}
        
      

Notice how the schema defines two root types:

  1. A query type called MyQueryRootType; and,
  2. A mutation type called MyMutationRootType.

A shorter way of rewriting the above code omits the schema definition by renaming the root types as default root operation types (i.e. Query, Mutation, Subscription):

        
type Query {
  someField: String
}

type Mutation {
  setSomeField(to: String): String
}
        
      

A schema definition should always be omitted if it only uses the default root operation type names i.e. Query, Mutation and Subscription.

GraphQL Object Type Validation Rules

Remember how we said that GraphQL schema is defined by types and directives and root operation types? These Object types can also be invalid if incorrecly defined.

Object Types MUST adhere to the following rules in GraphQL schema design:

  1. An Object type MUST define a field.
  2. An Object type can declare a unique interface.
  3. An Object type must be a super-set of all interfaces it implements.

Designing GraphQL Schema with the GraphQLSchema Object

GraphQL.js provides the foundation for libraries such as graphql-tools. These tools are often used to generate and mock GraphQL.js schema.

In the GraphQL.js implementation of GraphQL, the graphql/type is the module that actually defines GraphQL types and schema.

A Schema is thus designed by providing root types for each operation.

        
class GraphQLSchema {
  constructor(config: GraphQLSchemaConfig)
}

type GraphQLSchemaConfig = {
  query: GraphQLObjectType;
  mutation?: ?GraphQLObjectType;
}
        
      

Under the hood, this process is no different than what we saw previously.

When using libraries such as graphql-tools in a modular way, your schema will be defined in a schema.js file while resolvers in a resolver.js will implement it.

Now that we've seen how to design GraphQL schemas, let's get to the good stuff...

Here are the best practices and recommendations for designing GraphQL schema.

Use Object Types instead of Field Types

One of the most important things when designing GraphQL schema is avoiding designs than will break with changes in the future. The problem is, it hard to know this in advance. That's where this best practice comes in.

Using Object types instead of simple types in fields whenever possible is often a good rule of thumb. It future-proofs your schema - to a degree.

Take the following Event Object type, for example.

        
type Speaker {
  id: ID!
  location: String!
  conference: String!
  duration: [DateTime!]!
}
        
      

Notice that the duration field is a Non-null List of times. This List (which is actually a range) will, conceivably, hold the start and stop times accounting for the duration of time allocated to a speaker.

Nothing wrong here.

Now, think of a scenario than would make us need to add some more data to the duration field. A speaker taking more time than allocated, for example.

This information is not immediately apparent in our List. And to remedy that, we are now faced with two breaking options when we evolve this schema:

  1. Deprecate duration for something more appropriate, say, three new fields; startTime, stopTime, allocatedTime. This way, immediately any talk is underway we can compute its projected stop time using allocated duration time (what we want to do). And now it's easier to keep track of event schedules for talks are underway and those yet to begin.
  2. Refactor duration to accomodate the new data. The problem will be that we cannot add our data to duration without restructuring it to something other than a DateTime array.

Let's backtrack a bit and assume we had designed this schema a little differently.

        
type Speaker {
  id: ID!
  location: String!
  conference: String!
  duration: Duration!
}

type Duration {
  startTime: DateTime!
  stopTime: DateTime!
}
        
      

While this example is trivial, it is easy to see that we can can easily add more fields in duration without breaking anything.

We can now tuck in way more data in our Duration Object type as follows:

        
...

type Duration {

  ...

  allocatedTime: DateTime!
  extraTime: DateTime!
  extraTimeReason: String!

  ...

}
        
      

More importantly, we are now adding data that is not even DateTime. Notice extraTimeReason is a String.

Use Descriptive Object Type Names

We saw which names you can and cannot use in the internal schema design validation rules earlier.

The validation rules touched on the rules that determine what is a valid Object type name and which is not. Following the same logic...

GraphQL Object Field Validation Rules

...the spec also requires that each field of an Object Type:

  1. What, we said about Object types names i.e. must be unique, cannot begin with a double underscore;
  2. MUST return a type if IsOutputType(fieldType) returns true; and,
  3. For each argument of a field, again, what we said about Object types names.

Now...with these simple validation rules as the only guidelines, it's tempting to design your schema using simple Object type names. Conference, for example, instead of something like GraphQLEuropeConference.

Remember that we want to future-proof our schema as much as possible.

We could ofcourse store data on our conferences in the Conference object type rather easily.

        
type Conference {

  ...

  id: ID!
  speaker: Speaker!
  topic: String!
  company: String!

  ...

}

type Speaker { ... }

type Duration { ... }
        
      

And this would work perfectly.

You could even argue against something a bit more specific as being less modular (harder to scale even).

Until sometime later you have a somewhat different type of conference than what is currently stored, with some different fields.

Now, we have to refactor our schema design to include these new conference.

To illustrate this, let's assume that instead of company conferences with a single speaker from each company now we have a regional annual conferences with multiple speakers. And the general topic is GraphQL where topic actually holds a list of GraphQL sub-topics.

        
type Conference {

  ...

  id: ID!
  speaker: Speaker!
  topic: String!
  company: String!

  ...

}

type GraphQLEuropeConference {

  ...

  id: ID!
  speakers: [Speaker!]!
  topics: [Topic!]!
  year: DateTime!


  ...

}

type Speaker { ... }

type Topic { ... }

type Duration { ... }
        
      

Our schema now looks odd.

Yes. I know odd is not a good enough technical reason to do anything.

The problem here is we are stuck with the old Conference Object type and it looks like a generalization of our new annual GraphQLEuropeConference Object type. This is clearly confusing to anybody trying to understand our schema...

...and completely avoidable.

Here are some problem statements:

  1. We have to refactor or deprecate some fields in Conference, somehow. Or do we name an actual GraphQL topic as sub-topic in our schema instead to avoid the confusion? Heck! That still leaves us with the a topic field and Topic Object type.

All this mucking around, for such a seemingly trivial addition!

At some point, we have to rename Conference to...wait for it... something more specific! Which we could have done in the first place.

This breaking changes clean up our schema as follows:

        
type CompanyConference {

  ...

  id: ID!
  Speaker: Speaker!
  topics: [Topic!]
  company: String!

  ...

}

type GraphQLEuropeConference {

  ...

  id: ID!
  speakers: [Speaker!]!
  topics: [Topic!]!
  year: DateTime!


  ...

}

type Speaker { ... }

type Topic { ... }

type Duration { ... }

        
      

Use Fields and Types over custom Scalars

  • Keep your schema modular (Folder structure)
  • Try to Avoid versioning
  • Use Nullability where appropriate
  • Pagination
  • Batching
  • Be specific with naming
  • Be specific with naming
  • Be specific with naming
  • Be specific with naming
  • Be specific with naming