A GraphQL structure; simple, flexible, sensible

A GraphQL structure; simple, flexible, sensible

Unlock a flexible GraphQL structure that scales with your app

Back in 2016, I started using GraphQL on a personal project. Since then, I’ve advocated for its use in all my consulting activities. I do not need to convince you, a savvy developer, GraphQL offers many advantages over its predecessors REST and SOAP. In the many projects I’ve led, there were some hard lessons learned about architecting the graph correctly.

We are going to use a simple Graph about automobiles. Find the Full Graph below, for now, let’s get started with our first lesson.

Some General Guidelines

  1. Query operations are plural and return an array - cars(input: CarsInput): [Car!]!
  2. Mutation operations are singular - car(input: CarInput): Car!
  3. Delete has a dedicated operation, receives an ID and returns a Boolean - deleteCar(id: ID!): Boolean!
  4. Never have a Mutation and Query with the same name
  5. Fragments should only include primitive properties, never nested properties

Use plurals for Queries

Query name is plural, always. This might be a bit controversial, but most of our queries fetch an array of the item. You heard me correctly. Even if we only want to fetch one item by ID, we use the cars query and pull out the first one from the array. It’s really not as bad as you may think. See below for a Vue.js example how to do this with vue-apollo.

type Query {
  cars(input: CarsInput): [Car!]!
}

input CarsInput {
  id: ID
  makerId: ID
}

type Car {
  id: ID
  name: String
  maker: Maker
}

Advantages

  • You now have one operation to hit that can pivot based on the input. Pass it just an id and it will return just one car in the array. Pass it a makerId and you receive multiple cars in the array.

Disadvantages

  • The client always receives an array, even when querying for a single ID. Anytime a client wants to fetch just one car, it will receive an array, it needs to pull out first index of the array for its item.

    update (data) {
      return data.cars[0]
    },

Exceptions

  • There are some occasions where there is only ever one item that will be returned. These can be singular. Queries such as myProfile or version.

Use singular for create / update Mutations

The mutations for creating / updating an item are singular. This means you will create / update items one at a time.

type Mutation {
  car(input: CarInput): [Car!]!
}

input CarInput {
  id: ID
  name: String

  # Use this to add relationships to this item
  makerId: ID
  imageId: ID
}

type Car {
  id: ID
  name: String
  maker: Maker
}

Advantages

  • You now have one operation to hit that can pivot based on the input. Pass it an id and it will update the item. When the mutation is called without an id, then it will create the item.
  • Reduce duplicate resolvers that return the same response.
  • Also used to assign the item to any relation. For example, if a Car has images, you can create the image, then associate the image with the item using the imageId field. This example uses the car mutation to assign an image to the car.
    variables () {
      return { input: { id: '123', imageId: '555' } }
    }

Disadvantages

  • It only allows creating / updating an item one at a time. To create or update multiple items you will need to send separate requests, or combine them all in one request. The latter can be done, but seems to be used less often.

    Query

    mutation car($input1: CarInput!) {
      car(input:$input1) {
        ...car
      }
    }
    mutation car($input2: CarInput!) {
      car(input:$input2) {
        ...car
      }
    }

    Variables

    variables: {
      input1: {
        id: '123',
        name: 'Hello',
      },
      input2: {
        id: '456',
        name: 'Bye',
      },
    }

Delete / Remove has a dedicated operation

Deleting a type requires a dedicated mutation operation that returns Boolean if the delete succeeded or not. To remove a relationship would also be a dedicated mutation.

type Mutation {
  deleteCar(id: ID!): Boolean

  # To remove a relationship, you would also create a dedicated mutation
  removeCarImage(carId: ID!, imageId: ID!): Boolean
}

Advantages

  • Separates updating an item from deleting the item.
  • Access control can be separate from create/update.
  • Simplifies the deletion activity, while not creating specific operation.

Disadvantages

  • It only allows deleting one an item one at a time.

Fragments

When creating a fragment, never include any nesting variables. What do I mean

Take this type for example

type Car {
  id: ID
  name: String
  make: Maker
  images: [Image!]!
}

fragment car on Car {
  id
  name
}

Notice that in the fragment we do NOT include either make or images. We want our fragments to be just that, the fragment of the base type. If we automatically included make and images in our fragment, then it would automatically pull down those values when we used this fragment, maybe this might be what you want, but it bloats your queries, when combining fragments.

Instead extend them in your query

query cars($input:CarsInput) {
  cars(input:$input) {
    ...car
    make {
      ...maker
    }
    images {
      ...image
    }
  }
}
## TODO: Include car, image, and maker fragments here

Labels

When you find you’re formatting the data on the client, roll that formatting back to a type resolver. Full name is a good example for a person. Take the given GQL.

type Person {
  id: ID
  firstName: String
  lastName: String
  fullName: String
}

Now in your resolver, combine them for the front end to use.

exports.types = {
  Person: {
    fullName (person) {
      return `${person.firstName} ${person.lastName}`
    },
  },
}

Directives for repetitive fields, such as createdAt, updatedAt

directive @timestamps on OBJECT
type Car @timestamps {
  id
  # createdAt automatically included
  # updatedAt automatically included
}
const GraphQLDate = require('./date-scalar')
const { SchemaDirectiveVisitor } = require('apollo-server-express')

module.exports = class TimestampDirective extends SchemaDirectiveVisitor {
  visitObject (type) {
    const fields = type.getFields()
    if ('createdAt' in fields) {
      throw new Error('Conflicting field name createdAt')
    }
    if ('updateAt' in fields) {
      throw new Error('Conflicting field name updatedAt')
    }

    fields.createdAt = {
      name: 'createdAt',
      type: GraphQLDate,
      description: 'Created At Timestamp',
      args: [],
      isDeprecated: false,
      resolve (object) {
        // UPDATEME to your specific field logic
        return object.created_at || object.createdAt
      },
    }

    fields.updatedAt = {
      name: 'updatedAt',
      type: GraphQLDate,
      description: 'Updated At Timestamp',
      args: [],
      isDeprecated: false,
      resolve (object) {
        // UPDATEME to your specific field logic
        return object.updated_at || object.updatedAt
      },
    }
  }
}

The Full Graph

type Query {
  # All queries are plural
  cars(input: CarsInput): [Car!]!
}

type Mutation {
  car(input: CarInput): Car!

  # Dedicated mutations for deleting items
  deleteCar(id: ID!): Boolean
}

# Input for the Query
input CarsInput {
  id: ID
  maker: ID
}

# Input for the Mutation
input CarInput {
  id: ID
  name: String

  # Use this to add relations to this item
  makerId: ID
  imageId: ID
}

type Car {
  id: ID
  name: String
  maker: Maker
}

fragment car on Car {
  id
  name
}

Objections

What about pagination?

A little out of the scope of this writeup, but you can add pagination to a query by including another parameter with your input, such as page.

type Query {
  # All queries are plural
  cars(input: CarsInput, page: Pagination): [Car!]!
}

input Pagination {
  limit: Int
  startId: ID
  endId: ID
  orderBy: String
}

What about access?

Depending on your needs, I’ve found a directive works well here. I’ll include the @isAuthenticated directive I use on my apps for your reference. The directive looks for the user property on the Express JS request that is handled by express-jwt.

Usage

type Query {
  cars(input: CarsInput): [Car!]! @isAuthenticated
}

The is-authenticated-directive.js file

const { defaultFieldResolver } = require('graphql')
const { AuthenticationError, SchemaDirectiveVisitor } = require('apollo-server-express')

module.exports = class IsAuthenticatedDirective extends SchemaDirectiveVisitor {
  // Visitor methods for nested types like fields and arguments
  // also receive a details object that provides information about
  // the parent and grandparent types.
  visitFieldDefinition (field) {
    const { resolve = defaultFieldResolver } = field
    field.resolve = async function (...args) {
      const [, , ctx] = args
      if (ctx.req && ctx.req.user) {
        return resolve.apply(this, args)
      }
      throw new AuthenticationError(
        'You are not authorized to view this resource.'
      )
    }
  }
}

Notes

  1. Per the GraphQL spec, ID is the same thing as String. We just use ID to signify that it’s an internal ID (such as a UUID).
  2. Should login be a Query or a Mutation. The only fundamental difference between Query and Mutation in GraphQL, when submitting multiple in one request, Queries are run in parallel, while Mutations are run in a series. There is sometimes confusion between which should be used. In the case of logins, I highly suggest using a Mutation.
  3. Mutations are not usually cached by client software, logins should never be cached.
  4. Apollo persistent queries will send the request as a GET with the variables in the URL, this is no good.
  5. Errors? Use the provided error handler from GraphQL. I’ve seen some fancy ways to try to include errors in the data response, it just complicates things dramatically.

Recognition