DEV Community

Konstantin
Konstantin

Posted on

Design predictable GraphQL API

Today I am going to tell you how we design our GraphQL API and how we make it predictable.

Unfortunately, knowledge of GraphQL doesn’t answer the question how to design API. And I would be happy to find such an article when we started. Every time we faced problems, we had to make decisions on the go. Some of them took root, while others were altered as a result of retrospective. Finally, we made a number of strict rules that have been helping us structure our API without freedom of interpretation for a long time. I have no illusion that these rules will work for every project. But I'm sure they can help many of us find answers when we need them.

Queries

Let's take a look at the book accounting service API for a some Library as an example. We will operate with such entities as author, book, collection and user.

Rule #1: provide a resolver to get entity data named according to entity name

In our example, we will declare 4 resolvers to get the data of the corresponding entities

type Query {
  author(id: ID!): Author 
  book(id: ID!): Book
  collection(id: ID!): Collection
  user(id: ID!): User
}
Enter fullscreen mode Exit fullscreen mode

In relationships between entities, this rule also works

type Book {
  id: ID!
  title: String!
  author: Author!
}
Enter fullscreen mode Exit fullscreen mode

Rule #2: provide a resolver to get a list of entity data named according to the entity's plural name*

There are several nuances here. Data lists can be either limited or not. For small limited lists, you can simply return an array of items

type Collection {
  id: ID!
  title: String!
  books: [Book!]!
}
Enter fullscreen mode Exit fullscreen mode

But of course, we cannot do this with either authors, books, or users, because these lists have no limits.

Rule #3: unlimited lists must be paginated

There are two common types of pagination: offset-based and cursor-based. The truth is, you will most likely want to use both for different tasks. So, for the offset-based paginated list we leave the entity name in the plural and for cursor-based paginated list we add the Connection suffix (*).

type Query {
  authors(offset: Int, limit: Int): Authors
  books(offset: Int, limit: Int): Books
  booksConnection(
    before: String
    after: String
    first: Int
    last: Int
  ): BooksConnection
  collectionsConnection(
    before: String
    after: String
    first: Int
    last: Int
  ): CollectionsConnection
  users(offset: Int, limit: Int): Users
}
Enter fullscreen mode Exit fullscreen mode

As you can see, the object types for paginated lists are named according to the resolvers.

Rule #4: resolvers must be universal regardless of the client's access level

You should not create different resolvers to receive data from the same entity, depending on the access level. There are other ways to differentiate access to fields such as schema directives.

# 😑 Bad way
type Query {
  user(id: ID!): User
  userByAdmin(id: ID!): UserByAdmin
}

type User {
  id: ID!
  login: String!
}

type UserByAdmin {
  id: ID!
  login: String!
  email: String!
}
Enter fullscreen mode Exit fullscreen mode
# 😊 Good way
directive @auth(requires: Role = ADMIN) on OBJECT | FIELD_DEFINITION

enum Role {
  ADMIN
  GUEST
}

type Query {
  user(id: ID!): User
}

type User {
  id: ID!
  login: String!
  email: String! @auth(requires: ADMIN)
}
Enter fullscreen mode Exit fullscreen mode

Rule #5: add resolvers as fields to entity types to calculate their characteristics instead of separate resolvers

In GraphQL, any field of an object type can return not just the data stored in it, but be processed by a separate resolver. Due to this, the entity dataset can be enriched with additional, calculated parameters.

# 😑 Bad way
type Query {
  book(id: ID!): Book
  isBookBorrowed(id: ID!): Boolean
}
Enter fullscreen mode Exit fullscreen mode
# 😊 Good way
type Query {
  book(id: ID!): Book
}

type Book {
  id: ID!
  title: String!
  author: Author!

  isBorrowed: Boolean!
}
Enter fullscreen mode Exit fullscreen mode

Rule #6: if requested data doesn't exist, you must return null or empty list [] instead of an exception

The main point of this rule: you should not throw an exception in query resolvers. Lack of data is not a error.

You may have noticed in the query resolvers examples above, there is no Non-Null modifier (!) for the return data type. It means that these resolvers can return null instead of object types.

In cases where a list is supposed, you must return an empty array []. You should always use the Non-Null modifier in conjunction with the list modifier [Entity!]!. You don't need other combinations [Entity], [Entity!] or [Entity]! which will allow you to pass null instead of a list or as an item.

type Query {
  collection(id: ID!): Collection
}

type Collection {
  id: ID!
  title: String!
  books: [Book!]!
}
Enter fullscreen mode Exit fullscreen mode

Mutations

Rule #7: CRUD mutations of an entity must be named according to the pattern ${operation}${Entity}

type Mutation {
  createUser(userData: CreateUserInput!): User!
  updateUser(userData: UpdateUserInput!): User!
  deleteUser(id: ID!): User!
}
Enter fullscreen mode Exit fullscreen mode

Rule #8: CRUD mutations of an entity must return entity data

As in the example above, even a delete mutation returns deleted entity data.

Rule #9: it is necessary to distinguish between arguments that are heterogeneous data and the input data of an entity

Parameters of single entity must be combined into input object type and passed in one argument.

type Mutation {
  createUser(userData: CreateUserInput!): User!
}

input CreateUserInput {
  login: String!
  email: String!
}
Enter fullscreen mode Exit fullscreen mode

Dissimilar arguments must be passed separately.

type Mutation {
  borrowBook(userId: ID!, bookId: ID!): Borrowing!
}
Enter fullscreen mode Exit fullscreen mode

Rule #10: the name of the input type must match the pattern ${Mutation}Input

type Mutation {
  createUser(userData: CreateUserInput!): User!
  updateUser(userData: UpdateUserInput!): User!
}
Enter fullscreen mode Exit fullscreen mode

Rule #11: the name of the argument corresponding to the entity's input type must match the pattern ${entity}Data.

type Mutation {
  createUser(userData: CreateUserInput!): User!
  updateUser(userData: UpdateUserInput!): User!
}
Enter fullscreen mode Exit fullscreen mode

Rule 12: all fields of the input type of the update mutation except the identifying ones must be optional

type Mutation {
  updateUser(userData: UpdateUserInput!): User!
}

input UpdateUserInput {
  id: ID!
  login: String
  email: String
}
Enter fullscreen mode Exit fullscreen mode

This rule allows you to use a single mutation to update different parts of the entity separately.

mutation UpdateUserEmail {
  updateUser(
    userData: {
      id: "User ID"
      email: "New e-mail"
    }
  ) {
    id
    email
  }
}
Enter fullscreen mode Exit fullscreen mode

Rule 13: if for some reason the operation cannot be performed, an exception must be thrown

In this regard, mutations are different from queries.

Conclusion

The subscriptions are remaining overboard for now. We continue to gain experience with them and maybe later I will share the results with you.

I would love to discuss these 13 rules with you (don't let the number scare you). Please share your experience on how you approach API design in your projects. Tell me in what cases these rules will not work and how you get out of the situation.

Thank you for your attention!

Top comments (0)