Lessons Learned From Building Too Many GraphQL Applications

Refresher

  • GraphQL was developed internally by Facebook.
  • Utilizes a single endpoint.
  • Relies on the consumer to specify the "query".

Is it better than REST?

In my opinion, it is superior in every way.

 

But it introduces its own problems and caveats.

Who is using it?

So ... Everyone?

Pros

Addresses the following shortcomings of REST:

 

  • Overfetching: Fetching too much data when using very little of it.
  • Underfetching: Fetching data from multiple endpoints then stitching them up.
  • Documentation: The GraphQL schema is self-documenting to an extent.
  • Standardized: Unlike REST, GraphQL has a standard and an RFC process.

Overfetching

Typical REST response:

{
  users: {
    items: [
      // useless user objects elements
    ],
    pageInfo: {
      totalCount: 150000,
      currentPage: 1,
      perPage: 100
    }
  }
}

👈I only needed this

Underfetching

{
  "courses": [
    {
      "id": 1,
      "name": "Intro to Software Engineering"
    },
    {
      "id": 2,
      "name": "Why you should stop using Angular"
    },
    {
      "id": 3,
      "name": "Refactoring UI"
    }
  ]
}
{
  "availability": [
    {
      "courseId": 1,
      "available": false
    },
    {
      "courseId": 2,
      "available": true
    },
    {
      "courseId": 3,
      "available": false
    }
  ]
}

Having to call more endpoints as more data is needed

Fun Tip: By solving overfetching in some scenarios, you might cause undefetching in others

Self Documenting Schema

To an Extent...

Cons of GraphQL

  • Easy to miss Performance Problem (N + 1)
  • Lack of standard for uploading files
  • Larger Incoming Payloads
  • Easily abused with N depth queries

Lessons Learned

Schema != Database

Don't map your database to the GraphQL schema

type User {
  _id: ID!
}
type User {
  id: ID!
}

Vs

🤮

😎

Schema != Database

Use nested queries when it makes sense

type Post {
  user_id: ID!
}
type Post {
  author: User!
}

Vs

🤮

😎

Schema != Database

Use nested queries when it makes sense

type Query {
  userPosts (userId: ID!, skip: Int, first: Int): [Post!]!
}
type User {
  posts (first: Int, skip: Int): [Posts!]!
}

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

🤮

😎

Schema Driven- Validation

Using scalars for type validation

Using directives for constraints

Type Validation

A lot of data constrains can be their own type

  • Email
  • Positive Integers
  • Phone Numbers
  • URL
  • Timestamp
  • DateTime

Not only you are making it clear what type of data your API accepts but also you are explicit about the consumer responsibilities

 

Implicit Validation

Explicit Validation

Tip for Mobile Devs

You can map custom scalars defined by the API to your own language's native type

For example the API could have a Timestamp that is sent as a `string` between requests but parsed as a native DateTime object on the client side.

Directives

Directives are like a middleware that can be applied on queries or fields.

type User {
  secret: String! @can(ability: "SEE_SECRETS")
}

Directives

Use them when you have a "varying" constraint on a given query or a field.

input PostInput {
  title: String! @min(length: 5)
}

Nullable != Optional

type Mutation {
  updateName(name: String): [ID!]!  
}

Which of these are valid payloads 🤔

{
  "name": null
}
{
  "name": ""
}
{

}

N + 1 Problem

type Post {
  id: ID!
  title: String!
  author: Author!
}

type Author {
  id: ID!
  name: String!
}

query Posts {
  posts {
    author {
      name
    }
  }
}

Fixing N + 1

In Node.js (JavaScript) we have this thing called the event-loop, which we can abuse to batch the "calls" to the resolvers.

async function batchFunction(keys) {
  const results = await db.fetchAllKeys(keys);
  return keys.map(key => results[key] || new Error(`No result for ${key}`));
}

const loader = new DataLoader(batchFunction);

This isn't Node.js specific and can be done with any language.

Infinite Depth Queries

type Post {
  id: ID!
  title: String!
  author: Author!
}

type Author {
  id: ID!
  name: String!
  posts: [Post!]!
}

query Posts {
  posts {
    author {
      posts {
      	author {
          posts {
          	author {
              posts {
              	# and so on
              }
            }
          }
        }
      }
    }
  }
}

Limiting Query Depth

Just use a plugin or a library to limit the query depth:

Pagination Best Practices

Pagination Best Practices

type Query {
  posts (skip: Int, first: Int): [Posts!]!
};

The classic

Pagination Best Practices

Slightly Advanced

type PageInfo {
  perPage: Int!
  totalPages: Int!
  totalCount: Int!
  hasPreviousPage: Boolean!
  hasNextPage: Boolean!
}

type PostPagination {
  items: [Posts!]!
  pageInfo: PageInfo!
}

type Query {
  posts (skip: Int, first: Int): PostPagination!
};

Cons of sliced pagination

Non-Uniform Paging: Fetching pages of different lengths, useful to adapt to user internet speed or any arbitrary reason

Assume I fetch the first 10 items, then the next 5 items. What page am I on now? 🤔

Cursor Based Pagination

Instead of using a `skip` and `limit` slicing, you could use an opaque cursor or a pagination id.

type PageInfo {
  hasNext: Boolean!
  hasPrevious: Boolean!
  endCursor: String
  startCursor: String
}

type PostPagination {
  items: [Posts!]!
  pageInfo: PageInfo!
}

type Query {
  posts (after: String, before: String, first: Int): PostPagination!
};

Other pagination best practices

Using Edges and Nodes to represent relational data

Mutations Best Practices

NEVER EVER 💀🛑

Nest Mutations

input AddBookInput {
  ISBN: String!
  title: String!
}

input RemoveBookInput {
  bookId: Int!
}

input UpdateBookInput {
  ISBN: String!
  title: String!
}
      
type AuthorOps {
  addBook(input: AddBookInput!): Int
  removeBook(input: RemoveBookInput! ): Boolean
  updateBook(input: UpdateBookInput!): Book
}

type Mutation {
  Author(id: Int!): AuthorOps
}

Mutation Responses

type Mutation {
  postUpdate (id: ID!, input: PostInput!): Post
}

Cute, but we have no way to know if the post was not found or if the update failed.

"What is an exception?"

Is a failed login attempt really an exception?

Is an authentication failure an exception?

Think in frontend

To-throw or not depends on the ability of frontend to recover from such exception

If the consumer of your API can recover from an error and that error is an expected possible outcome, don't throw!

interface MutationResponse {
  code: String!
  success: Boolean!
  message: String!
}

type PostMutationResponse implements MutationResponse {
  code: String!
  success: Boolean!
  message: String!
  post: Post
}

type Mutation {
  postUpdate (id: ID!, input: PostInput!): PostMutationResponse!
}

With mutation responses:

Mutation Respones Pros:

  • Consistent return types.
  • Fits more fields for multi-resource mutations.
  • Easier error recovery from "expected errors".
  • The consumer can alias fields to achieve better abstractions
  • Allows for optimistic response handling.

Mutation Responses + Aliasing

mutation UpdatePost ($id: ID!, $input: PostInput!) {
  response: updatePost (id: $id, input: $input) {
    success
    code
    message
    node: post {
      # Fields to patch the in-view item with
    }
  }
}

This convention makes mutations return the exact same structure. Your API consumer can build seamless mutation handling flows that work for any mutation.

Naming Convention

Compare the following styles:

[action][object]

createUser
createPost
createProduct
updatePost
updateProduct
[object][action]

userCreate
userUpdate
postCreate
postUpdate
productCreate

seen in Product Hunt GraphQL API

Follow the Standard

GraphQL is a standard query language. Follow the specs whenever possible.

Status Codes: GraphQL spec doesn't care about transports (HTTP), status codes are meaningless and 200 is the same as 401

Uploading Files: GraphQL spec doesn't handle files, there is an RFC implementation.

Thanks 👋

Lessons Learned From Too Many GraphQL Applications

By Abdelrahman Awad

Lessons Learned From Too Many GraphQL Applications

  • 826