This schema style guide summarizes how we design the GraphQL schema at Kiron Open Higher Education. These principles are derived from our experience of running a GraphQL API over the last two years and inspired by the previous work of various people in the community.
This guide starts at a very high level and then tries to be more specific in how to achieve the overarching principles in the implementation.
At Kiron we use GraphQL to create flexible interfaces that enable us to build better products. Our software design evolves around the GraphQL schema. We want to build APIs that are intuitive to use and the API consumer's user experience is our highest value.
To achieve this we apply the following overarching principles:
Our API is more than just an database access layer. Instead it evolves around specific needs and use cases of our product. The GraphQL specification uses the following formulation.
Product‐centric: GraphQL is unapologetically driven by the requirements of views and the front‐end engineers that write them. GraphQL starts with their way of thinking and requirements and builds the language and runtime necessary to enable that.
Very little business logic should be handled by to the frontend because we cannot assume that the client knows the underlying rules. If the backend can handle certain calculation or business logic it should do so. In GraphQL fields are only calculated/resolved if they are demanded by the frontend. This allows us to create additional computed fields in our types without polluting the data model.
Example: A course is full when the number of participants reached the maximum amount of participants
type Course {
# bad
participants: Int!
maxParticipants: Int!
# good
full: Boolean!
}
While creating a flexible type model increases the type reusability and can lead to smaller type models we should instead aim for precise types. Exposed types should not only be tailored to the data models underneath but also to the requirements of the views accessing using the parts if the API. Precise types can reduce the amount of nullable fields. This helps the consumer of the API to know exactly what to expect. GraphQL features such as interfaces and type unions help us with that goal. Creating new types is cheap (compared to REST) and scalable. Therefore we can create stronger abstractions than our underlying SQL databases or REST resources. We are encouraged to derive from the underlying models to improve the user experience.
Example: A user can see certain data on themselves but not on other users. The frontend displays different components depending on whether the returned object is the user object of the viewer or a different user.
# bad
type User {
name: String!
birthDay: Date
friends: UserConnection
}
# good
interface User {
name: String!
}
type UserSelf implements User {
name: String!
birthDay: Date!
friends: UserConnection
}
type UserOther implements User {
name: String!
}
GraphQL makes use of a strict type system in the Schema Definition Languages (SDL) as well as in the query languages (GraphQL). In GraphQL every property or argument not only carries a name but also a type and a description. We can specify our API without creating external documentation, guides or naming conventions.
The API should be consistent in the way it is designed and shaped. This style guide will cover many rules to make the API consistent. Sometimes we have to go even further to deliver consistency on a higher level. Consistency might often be a tradeoff for another principle and the team is responsible to make decisions on when to favour consistency over other principles.
Every output object type that represents an entity should have a single identifier field that uniquely identifies the resource within the type. The identifier field is named id
and of type ID!
. The identifier field is owned by the API and while its value should not change over time, the possibility of change should be assumed*. The ID
scalar is serialised as string in GraphQL responses and has to be handled as as such in the frontend code.
* Mostly meaning, no hard coding of IDs in application code.
Generally types should only be nullable if this is a necessity of the domain (1 to 0..1 relationships). In contrast many companies prefer to make as little guarantees as possible (the hidden cost of non-nullable fields). In our (fairly small) API we have found that the only places where non-null guarantees are hard to make are 1 to 1 relationships.
List usually are not nullable. Instead a missing value is represented by the empty list. Lists should not contain null values since they carry no meaning on the type level.
Example:
type Person {
fiends: [Person]!
}
If the friends
field returns [null]
the meaning of the null
value is not obvious. One possible interpretation is that the friend represented by the null value is not known to the user. This is implicit knowledge that does not directly follow from the type system. There are many more explicit solutions like the introduction of a PersonFriendConnection
type that contains the viewerShownFriends
and the viewerHiddenFriends
.
Type names are in pascal case. That means they start with a capital letter. Type names should not contain whitespace dashes or underscores.
Examples | |
---|---|
Good | User , SocialSecurityNumber , UpdateUserResult |
Not allowed | user , social_security_number , updateUserResult |
Type names should always be singular. In GraphQL collections are expressed using lists, therefore a type should not be plural.
Examples | |
---|---|
Good | User , UpdateUserResult |
Not allowed | Users , UpdateUserResults |
Output types should be named according to the style rules and their name in the system. That means that type names should be consistent within the stack and with its usage in the organisation.
Types should not have their kind name (e.g. enum type or scalar type) in their name. If a type is an object type, enum type or scalar is already defined by the type system and does not have to be repeated in the name itself.
Examples | |
---|---|
Good | Gender , CourseStatus , Date |
Not allowed | GenderEnumType , CourseStatusEnum , DateScalar |
TODO
Input object types should be postfixed with Input
. Input types often behave in different ways than
Examples | |
---|---|
Good | updateUser -> UpdateUserInput |
Not allowed | updateUser -> UserUpdate |
Field names should be in camel case. That means they start with a capital letter. Field names should not contain whitespace, dashes or underscores.
Examples | |
---|---|
Good | user , socialSecurityNumber , updateProfile |
Not allowed | User , social_security_number , updateprofile |
Fields that return single (non list type) values should be singular, fields that return list types should be plural.
Examples | |
---|---|
Good | users: [User!]! , socialSecurityNumber: SocialSecurityNumber! |
Not allowed | user: [User!]! , socialSecurityNumbers: SocialSecurityNumber! |
Field names should be consistent with the underlying data model. Often we use our GraphQL API to hide away business logic implementation details and the names might derive.
Fields should be as short as possible and should neither contain the type name of the object type they are defined on nor their output type. Sometimes there is a grey zone when the type name is part of the name, e.g. dateOfBirth
, username
or googleCalendarId
.
Example:
type File {
# good
name: String!
# bad, from the context it is already clear that this is a name of a file
fileName: String!
}
type User {
# bad, the type and the name already indicate that this value is a date
birthdayDate: Date!
}
Arguments names should be in camel case. They are in that sense very similar to fields.
Examples | |
---|---|
Good | id , update , securityNumber |
Not allowed | ID , securityNumber |
For mutations all the same rules apply as for normal field types and the following naming conventions on top.
Mutation names should be composed of a verb that expresses the type of action happening and the object name the mutation is operating on.
Examples | |
---|---|
Good | updateProfile , setCourseStatus , trackPageView |
Not allowed | profile , setStatus , track |
For queries all the same rules apply as for normal field types and the following naming conventions on top.
Collection queries are queries that return a collection of (all) values of a type. The query field name should be the plural of the type name. In the example below the countries
query returns all values of the Country
type. The return value is a non-nullable list of non-nullable values.
Example: The countries
query returns all countries in the database.
type Query {
countries: [Country!]!
}
Collection queries can have a filter applied to them using an argument called filter
of specific input type created for the field composed of the typename and the postfix Filter
.
Example: To get all countries in Europe the countries field accepts a filter:
enum Continent {
ANTARCTICA
AFRICA
ASIA
EUROPE
NORTH_AMERICA
OCEANIA
SOUTH_AMERIC
}
input CountryFilter {
continent: Continent
}
type Query {
countries(filter: CountryFilter): [Country!]!
}
If a collection query cannot return any matching results it returns an empty array. It does not return null (not allowed by the schema) nor does it error.
By id queries are queries that query for a single element with a given id. The query name should be the singular of the type name (usually the type name itself since type names must be singular). The return value is nullable and null is returned when a value with the specified id is not found. The field does not error (by throwing a exception in the resolver) when the id cannot be located in the database.
Example: With the country
field a single country can be selected by id
type Query {
country(id: ID!): Country
}
Viewer queries are similar to the collection and by id queries but already have a filter parameter applied: The user id / account id of the viewer. Viewer queries are located inside of the viewer
namespace.
Example: The courses
root query returns all courses. The viewer.courses
field returns all courses that belong to the current viewer.
type Query {
viewer: Viewer!
courses: [Course!]!
}
type Viewer {
courses: [Course!]!
}
Mutations are in a sense very special because coming up with a good mutation design is one of the hardest parts when building a GraphQL schema. At Kiron we follow the approach outlined by Oleg Ilyenko in a [blog post on mutation] design(https://techblog.commercetools.com/modeling-graphql-mutations-52d4369f73b1), which practically applies the work of many other community members.
We design three types of mutations to cover all use cases. The types follow strong naming conventions. In the classic CRUD sense our queries are used for reading the data. What we are left with are create, update and delete operations for our data.
With our mutations we also follow our design principles mentioned above. The API should only offer mutations that are needed and save. This is not only a good practice to secure the API but also keeps the interface simple.
Create mutations are mutations that create a new entity in the database. The mutation is named after the type of entity it is creating. The mutations must be named in the following schema create<Entity Name>
. When you want to create a new User
the mutation to use is the createUser
mutation.
Create mutations usually take a single required argument draft
of the draft type of the entity <Entity Name>Draft
. The draft contains all the arguments that are used to create the instance. This allows us to make all arguments mandatory that are essential for the type and other fields nullable, that contain optional data for the creation. Mutations can have some implicit parameters for example the viewer or the current date.
Examples:
type Mutation {
# good
createUser(draft: UserDraft!): CreateUserResult!
createCertificate(draft: CertificateDraft!): CreateCertificateResult!
# bad
newUser(user: UserInput!): NewUserResult!
}
input CertificateDraft {
title: String!
score: Float
}
# Certificate draft would be enough to create Certificate type with fields
type Certificate {
id: ID!
title: String!
score: Float
createdAt: Date!
updatedAt: Date!
}
Delete mutations are used to delete a single entity from the database. The mutation is named after the type of entity it is deleting. The mutations must be named in the following schema delete<Entity Name>
. When a mutation deletes a User
the mutation is named deleteUser
.
Update mutations are mutations that change an existing entity. This change does not neccessarily map only to updates on the database layer (these could also be create or deletes, e.g. when connecting entities via joining tables).
Mutation results should contain the name of the mutation and should be postfixed with Result
. The mutation result is always non-nullable.
Examples | |
---|---|
Good | updateUser -> UpdateUserResult |
Not allowed | updateUser -> UserUpdate |
Mutation results have two standard fields that need to be present in every result object type:
The success
field indicates the status of the request and is either true
if the mutation succeeded or false
if it failed. The errors
field contains a list of user errors that occurred during the execution of the mutation. This allows us to differentiate between the errors that happen due to bugs or problems in the implementation and user errors like failed validations. While it is hard to draw a clear line the rule is that if the error might address the enduser it should be inside of the error field.
Mutation results should also always contain the updated or newly created entity usually in a field that is named after the type name.
Example:
type Mutation {
createUser(draft: UserDraft): CreateUserResult!
}
type CreateUserResult {
success: Boolean!
errors: [UserError!]!
user: User
}
Alternatively, mutation results can modelled as union types. In this case the result can either be a success or a failure. The benefit is that the type can be more precise in the sense that if the mutation is successful no null check on the updated entity (in the example user
) is needed.
Example:
type Mutation {
createUser(draft: UserDraft): CreateUserResult!
}
union CreateUserResult = CreateUserSuccess | CreateMutationFailure
type CreateUserSuccess {
user: User!
}
type CreateMutationFailure {
errors: [UserError!]!
}