Posts

About

Changing GraphQL Types Without Breaking Changes

May 25, 2020

Introduction

GraphQL does not have a versioning strategy built in for schema changes. This is a real head scratcher for new users, including myself a few years ago. Instead of versioning, GraphQL schemas can slowly be evolved over time.

Before each GraphQL schema change, backwards compatability must be considered. When doing this, think about it from the perspective of an old client that has not upgraded to the latest and greatest yet. If this client makes a request to the GraphQL server using the old schema, what is going to happen? Is everything going to work fine or are they going to be met with an error?

The company I work for has been evolving its schema over the past two years while maintaining backwards compatibility. In this post, I thought I would share some the techniques and gotchas in accomplishing this without breaking changes.

This post will cover changes to GraphQL types. See the next post for changes to queries and mutations.

Types

type User {
  username: String!
  email: String
  group: Group
}

type Group {
  id: ID!
  name: String!
}

Given the user type above, there are several schema changes that can be done including:

  • Adding a new field
  • Making a required field optional
  • Removing a field
  • Renaming a field
  • Changing a field’s types
  • Making an optional field required

Adding a New Field

type User {
  username: String!
  email: String
  group: Group
+ createdAt: String!
}

Adding a new field to an existing type is one of the easiest changes that can made to a GraphQL schema. This can be done without breaking changes. Why is this the case? For GraphQL operations, each field needed has to be specified. Not only that, but only those fields specified will be returned. This means that adding a new field will have no effect on existing operations.

Making an Optional Field Required

type User {
  username: String!
- email: String
+ email: String!
  group: Group
}

Making a optional field required is another trivial change to a GraphQL schema. Any clients using this field should already have handling for if the field is null. With this change the field will no longer be null. This means that the handling is no longer necessary, but will not break existing clients.

Removing A Field

type User {
  username: String!
  email: String
- group: Group
}

Removing a field can only take place when all clients no longer use it. This is usually used as a step when renaming a field or changing a field’s type.

Renaming A Field

type User {
  username: String!
  email: String
- group: Group
+ activeGroup: Group
}

Renaming a field requires some temporary backwards compatability and multiple deployments. First a new field can be added for the new name. In the example above this is the activeGroup. The old field will have to hang around since clients will still be using it. Resolvers will have to be setup to ensure that the same data is going to be both the new and old names. After the new field has been deployed, clients can start updating to use the new field. Once every client is updated the old field can be removed from the schema.

Changing A Field’s Type

type User {
  username: String!
  email: String
- group: Group
+ groupV2: GroupV2
}

+type GroupV2 {
+  id: ID!
+}

Occassionaly a type may need to go through some drastic changes. This may be a time for an entirely different type. This can be accomplished with similar techniques to renaming a field. First a new field can be created with the new type. In the case above, we are changing from group with a Group type to groupV2 to GroupV2 type. After the new field with the new type is added, clients can start updating to use the new type. Once every client has updated, the old field can be removed.

Making a Required Field Optional

type User {
-  username: String!
+  username: String
  email: String
  group: Group
}

Out of all the possible schema changes on types, this by far the hardest to do in terms of backwards compatability. It opens up the possibility of null pointer exceptions in any client code that uses the field. There are a couple paths forward, both equally frustrating to do.

First a new field can be added such as usernameV2 that is optional. The resolver for username must also be updated to always return a string. In this trivial case an empty string could be returned if the user did not have a username. The clients can be updated to new usernameV2 type gradually over time. When all of them are using it, the original username field can be removed. If the usernameV2 name is bothersome, then the steps above for renaming a field can be used to rename it back to username. This requires several deployments and updates to the clients that use this code. For more complex types, it may also be difficult to construct a default value to return when it no longer exists.

Another approach is to create a whole new type. For example we might be trying to model a guest user and a registered user with the same type. It may be better to leave the current User type alone and create a GuestUser type that has a different set of fields to it. This makes the changes to types easy but may impact the return types of queries and mutations.

When modeling a GraphQL type, be sure to think twice before making a field required. If unsure, then leave it as optional. It is much easier to go from optional to required than the other way around.

Conclusion

The last four type changes require knowledge of how the types are being used by clients of the schema. This is nearly impossible to do when the GraphQL API is public facing. Even if your GraphQL API is only internally facing, most type changes are costly to do in a backwards compatible way. Extra thought and care should be put into a field’s name, type, and if it is required or not. Doing this may save some headache and pain later down the road.


Written by Jacob Oakes
I am a software architect who enjoys learning new things, clean code, and automated tests.