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
}
In relationships between entities, this rule also works
type Book {
id: ID!
title: String!
author: Author!
}
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!]!
}
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
}
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!
}
# π 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)
}
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
}
# π Good way
type Query {
book(id: ID!): Book
}
type Book {
id: ID!
title: String!
author: Author!
isBorrowed: Boolean!
}
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!]!
}
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!
}
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!
}
Dissimilar arguments must be passed separately.
type Mutation {
borrowBook(userId: ID!, bookId: ID!): Borrowing!
}
Rule #10: the name of the input type must match the pattern ${Mutation}Input
type Mutation {
createUser(userData: CreateUserInput!): User!
updateUser(userData: UpdateUserInput!): User!
}
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!
}
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
}
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
}
}
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)