๐Ÿ“ฃ GraphQL Conf Hackathon 2024 โ€ข September 10-12 โ€ข Win $5000 cash prize Know more โ†’
Skip to main content
Back to Blogs

Design a GraphQL Schema So Good, It'll Make REST APIs Cry - Part 2

ยท 9 min read
Cover Image for Design a GraphQL Schema So Good, It'll Make REST APIs Cry - Part 2

What Do You Already Know? ๐Ÿง ๐Ÿ’ซโ€‹

GraphQL Schema Change Quiz!

Question 1/5

Adding a new field to a GraphQL schema is generally a:

In our previous post, we learned scalable GraphQL schema is critical for building production-ready APIs that can evolve with your application's needs.

In this post, we will dive deeper into how to continuously evolve your schema to meet your application's changing requirements without hard-coded versioning.

Adding Without Breaking: The Art of Additive Changesโ€‹

You know that feeling when you're working on a project, and suddenly you realize your schema needs to change? Maybe you need to add a new field, modify an existing one, or even remove something entirely. It's enough to make any developer break out in a cold sweat, right?

But fear not! I'm here to show you how to evolve your schema like a pro, keeping your API fresh and exciting without causing your clients to tear their hair out.

The Good, The Bad, and The Ugly of Schema Changesโ€‹

Not all changes are created equal. In this section, weโ€™ll analyze a few different types of changes and what makes them safe or unsafe.

First things first, let's break down the types of changes we might make to our schema:

  1. Safe Changes: These are the golden children of schema evolution. You can make these changes anytime, and your clients won't even bat an eyelash.
  2. Dangerous Changes: These are the sneaky ones. They might not break anything outright, but they can cause subtle issues that'll have your clients scratching their heads. We'll need to proceed carefully here.
  3. Breaking Changes: The name says it all. These changes will send your clients' applications crashing down faster than you can say "GraphQL". We want to avoid these like the plague, but sometimes they're necessary. Don't worry, I'll show you how to handle them like a pro.

Additive Changesโ€‹

Most of the time, these are safe as houses.

For example, adding fields & adding types is unlikely to cause issues for clients. But, there are a few tricky scenarios to watch out for.

The Optional Argument Conundrumโ€‹

Adding optional arguments is generally safe - it's like offering your clients a shiny new toy without forcing them to play with it.

However, there's a catch. Check this out:

  type Query {
- products(category: String): [Product!]!
+ products(category: String, inStock: Boolean): [Product!]!
}

See what we did there? We added an optional inStock argument. Seems harmless, right?

Let's dive deeper into why changing the behavior of a resolver when an optional argument isn't provided can be problematic:

type Query {
products(category: String, inStock: Boolean): [Product!]!
}

Imagine you have clients that have been using this query:

query {
products(category: "Electronics") {
name
price
}
}

If your resolver suddenly starts filtering out out-of-stock products when inStock isn't provided, these clients will unexpectedly receive fewer results. This could break their UI or data processing logic.

To avoid this issue, you can implement a strategy to handle the absence of the inStock argument gracefully in your resolver, so that the behavior remains consistent for clients.

The Required Argument Trapโ€‹

Now, this is where things get spicy ๐ŸŒถ๏ธ.

Adding a required argument is almost always a breaking change.

But, fear not! There's a way out:

  type Query {
- products(category: String): [Product!]!
+ products(category: String, sortBy: SortOption!): [Product!]!
}

This change is breaking, but it doesn't have to be.

You can provide a default value for the new argument to keep your existing clients happy.

type Query {
- products(category: String): [Product!]!
+ products(category: String, sortBy: SortOption! = POPULARITY): [Product!]!
}

See that = POPULARITY? That's your get-out-of-jail-free card. By providing a default value, you've made this addition safe.

Existing clients will use the default, and new clients can take advantage of the sorting option.

The Interface and Union Twistโ€‹

Now, let's talk about some trickier additive changes that can catch you off guard if you're not careful.

Adding New Interface Implementationsโ€‹

Adding a new type that implements an existing interface might seem harmless, but it can cause some unexpected behavior. Check this out:

interface Node {
id: ID!
}

type User implements Node {
id: ID!
name: String!
}

type Team implements Node {
id: ID!
name: String!
}

type Organization implements Node {
id: ID!
name: String!
employees: [User!]!
}

By adding the Organization type, we've expanded what could be returned by queries selecting for Node. This could break clients that aren't prepared to handle new types. Always encourage clients to use proper type checking.

query {
node(id: "1") {
... on User {
name
}
... on Team {
name
}
... on Organization {
name
employees {
name
}
}
}
}

Without proper type checking, clients might encounter these issues:

  1. Runtime Errors: If a client assumes all Node types have only a name field, they might try to access employees on a User or Team, causing errors.
  2. Missing Data: Clients might not display Organization-specific data if they're not prepared to handle it.
  3. Incorrect Data Processing: Business logic that assumes only User and Team types exist might produce incorrect results.

To mitigate these issues:

  1. Use TypeScript or Flow on the client-side to catch type errors at compile-time.
  2. Implement exhaustive type checking in your client code:
function handleNode(node: Node) {
switch (node.__typename) {
case "User":
return handleUser(node)
case "Team":
return handleTeam(node)
case "Organization":
return handleOrganization(node)
default:
const _exhaustiveCheck: never = node
throw new Error(`Unhandled node type: ${(_exhaustiveCheck as any).__typename}`)
}
}

This approach ensures that if a new type is added in the future, TypeScript will raise a compile-time error, prompting developers to update their code.

The Union Expansion Conundrumโ€‹

Similar to interfaces, adding new members to a union can cause runtime surprises. Consider this:

-  union SearchResult = User | Post
+ union SearchResult = User | Post | Comment

Surprise! Your clients might suddenly receive a type they weren't expecting. It's like opening a box of chocolates and finding a pickle - not necessarily bad, but definitely unexpected. Make sure to document how clients should handle these surprise types.

Let's delve into why union expansions can be tricky and how to handle them gracefully:

When you add Comment to the SearchResult union, existing clients might break in subtle ways:

  1. Incomplete UI: If the client only has UI components for User and Post, Comment results won't be displayed.
  2. Runtime Errors: Code that assumes only User and Post types exist might throw errors when encountering a Comment.

To handle this gracefully:

  1. Implement a fallback UI component for unknown types:

    function SearchResultItem({result}) {
    switch (result.__typename) {
    case "User":
    return <UserResult user={result} />
    case "Post":
    return <PostResult post={result} />
    case "Comment":
    return <CommentResult comment={result} />
    default:
    return <UnknownResultType type={result.__typename} />
    }
    }
  2. Encourage clients to use introspection queries to stay updated on schema changes:

    query {
    __type(name: "SearchResult") {
    kinds
    possibleTypes {
    name
    }
    }
    }

By implementing these strategies, clients can gracefully handle new union members without breaking existing functionality.

The Enum Evolutionโ€‹

Adding new enum values seems innocent enough, but it can impact client-side logic. Let's look at an example:

  enum OrderStatus {
PENDING
COMPLETED
+ CANCELED
+ REFUNDED
}

Clients that were using exhaustive switches might now have incomplete logic. Encourage clients to use default cases to handle new enum values.

switch (order.status) {
case "PENDING":
return "Order is pending"
case "COMPLETED":
return "Order is completed"
default:
return "Order status unknown"
}

Conclusionโ€‹

Evolving a GraphQL schema through additive changes allows you to expand your API's capabilities while maintaining backward compatibility. By following the principles and strategies outlined in this article, you can confidently add new fields, types, and arguments without causing disruptions to your clients.

Remember these key takeaways:

  1. Favor Additive Changes: Whenever possible, add new fields, types, or arguments instead of modifying existing ones. This approach maintains backward compatibility while allowing your schema to grow.

  2. Provide Transition Paths: Introduce new features alongside existing ones to allow gradual client adoption.

By treating your GraphQL schema as a product with its own lifecycle and evolution strategy, you can build APIs that are both powerful and adaptable. This approach allows you to innovate rapidly while providing a stable and reliable service to your clients.

Stay tuned for the next part of this series, where we will dive into removing schema elements and handling breaking changes!

Posted By

Amit Singh
Head of Growth and Strategy @ Tailcall