Skip to content
KeystoneJS LogoKeystoneJS
👋🏻 Keystone 5 has officially moved to maintenance only. For the latest release of Keystone please visit the Keystone website.

GraphQL Philosophy

Note: This is a conceptual introduction to how the Keystone team think about GraphQL APIs (and hence how Keystone's GraphQL API is generated). For more specific API docs, see the GraphQL API Introduction.

Goals

A good GraphQL API is a combination of the following criteria:

  • Quick prototyping no matter the client (mobile, desktop, other APIs, etc)
  • Be obvious, consistent, and predictable
  • Is mostly CRUD-based with escape hatches for Custom Operations
  • Match developer's domain knowledge
  • Be forward compatible with future unknown use-cases
  • Fully leverage the Graph of GraphQL through Relationships

Keystone's schema design

Keystone's auto-generated GraphQL Schema meets these goals by following a pattern with two distinct sets of things:

Domain objects

Modelled with CRUD (Create, Read, Update, Delete) operations, this covers the majority of functionality for most applications.

For example; the User type would have createUser / getUser / updateUser / deleteUser mutations.

Custom operations

Become apparent over time while building applications and adding to the schema.

For example; an authenticateUser / submitTPSReport mutation, or a recentlyActiveUsers query.

Tweet by Jess Telford: In my experience, the best GraphQL APIs have 2 distinct sets of things: 1. Domain Objects are modelled as type with CRUD mutations (`createUser`/`updateUser`/etc). 2. Common actions involving 0 or more Domain Objects are mutations (`sendEmail`/`finalizeTPSReport`)

Tweet by Jess Telford

Domain objects and CRUD

Every thing in your application / website / database which can be queried or modified in some way is a Domain Object. Each Domain Object has its own set of CRUD operations.

By modeling a schema in this way, it enables fast iteration with a consistent and predictable set of mutations and queries for every Domain Object.

To define a set of Domain Objects, it helps to think about it in terms of what a user will see. A blog site may have a series of Domain Objects, each with their own CRUD operations:

Domain ObjectCRUD
UserscreateUsergetUserupdateUserdeleteUser
PostscreatePostgetPostupdatePostdeletePost
CommentscreateCommentgetCommentupdateCommentdeleteComment
ImagescreateImagegetImageupdateImagedeleteImage

Tip: Because an Image may be uploaded and interacted with independently of a Post, or used across multiple posts, we're creating an Images list. Even if they're only used in a single Post, they still meet the definition as a thing which might be queried or modified in some way (for example, querying for a thumbnail version of the image, or updating alt text).

In general, Domain Objects map to Lists in Keystone:

keystone.createList('User', {
  /* ... */
});
keystone.createList('Post', {
  /* ... */
});
keystone.createList('Comment', {
  /* ... */
});
keystone.createList('Image', {
  /* ... */
});

To fully leverage the Graph of GraphQL, relationships between Domain Objects must be defined in a way that allows for both querying and mutating related data.

GraphQL gives us querying thanks to their type system:

GraphQL
type User {
  name: String
}

type Post {
  title: String
  author: User
}

type Query {
  getPost(id: ID): Post
}

Here you can see the Post.author field is defined as a relationship to a User. When doing a query, it follows a predictable pattern:

GraphQL
query {
  getPost(id: "abc123") {
    title
    author {
      name
    }
  }
}

Defining mutations requires a bit more setup and consideration to performing nested mutations.

Hint: Keystone implements this pattern with the Relationship type

Nested Mutations are useful when you need to make changes to more than one Domain Object at a time. Just like you may want to query for Post.author at the same time as getting Post.title, you may want to update User.name at the same time as you create a new Post.

For example, imagine a UI where an author could update their bio at the same time as creating a post. The mutation would look something like:

GraphQL
mutation {
  createPost(data: {
    title: "Hello World",
    author: {
      update: {
        bio: "Hi, I'm a writer now!"
      }
    }
  }) {
    title
  }
}

Note the data.author.update object, this is the Nested Mutation. Beyond update there are also other operations you may wish to perform:

Operation
connectConnect an existing item to the parent so future queries for related data return the connected item
disconnectBreak the connection with an existing item (but do not delete that item) so future queries for related data return null
createCreate a new related item and connect it to the parent so future queries for related data return this item
updateUpdate an already connected item's data
deleteDelete an already connected item and disconnect it from the parent so future queries for related data return null

Note: Since get is a query concern, and we're only dealing with nested Mutations, it is not included here.

This might be represented in the GraphQL Schema like so:

GraphQL
type User {
  name: String
  bio: String
}

type Post {
  title: String
  author: User
}

input CreateUserInput {
  name: String
  bio: String
}

input UpdateUserInput {
  id: ID!
  name: String
  bio: String
}

input CreatePostInput {
  title: String
  author: UserToOneRelationshipInput
}

input UpdatePostInput {
  id: ID!
  title: String
  author: UserToOneRelationshipInput
}

input UpdateUserToOneRelationship {
  create: CreateUserInput
  update: UpdateUserInput
  delete: ID
  connect: ID
  disconnect: ID
}

type Mutation {
  createPost(data: CreatePostInput): Post
  updatePost(data: UpdatePostInput): Post
  createUser(data: CreateUserInput): User
  updateUser(data: UpdateUserInput): User
}

Custom operations

Custom operations are an emergent property of the schema design. They are not something which should be defined up front.

As products are built, it will become obvious which operations are missing and what their inputs/outputs should be.

For example, while building out the TPS application, it became evident that at some point a TPS Report had to be printed and handed directly to a boss. There is no CRUD operation which can trigger printing a report. There are, however, the Printing and Courier services. A custom mutation can be made which uses both those services to complete the operation: submitTPSReport.

JS
const typeDefs = `
  type Mutation {
    submitTPSReport(TPSReportId: String, bossId: String): Boolean
  }
`;

const resolvers = {
  Mutation: {
    submitTPSReport: async (_, { TPSReportId, bossId }) => {
      await printService.printTPSReport(TPSReportId);
      const address = await getAddress(bossId);
      await courierService.submitJob({ from: 'printer', to: address });
      return true;
    },
  },
};

const server = new ApolloServer({ typeDefs, resolvers });

On this page

Edit on GitHub