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
- Query operations are plural and return an array -
cars(input: CarsInput): [Car!]!
- Mutation operations are singular -
car(input: CarInput): Car!
- Delete has a dedicated operation, receives an
ID
and returns aBoolean
-deleteCar(id: ID!): Boolean!
- Never have a Mutation and Query with the same name
- 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 amakerId
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
orversion
.
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 anid
, 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 theimageId
field. This example uses thecar
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
- Per the GraphQL spec,
ID
is the same thing asString
. We just useID
to signify that it’s an internal ID (such as a UUID). - 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
. - Mutations are not usually cached by client software, logins should never be cached.
- Apollo persistent queries will send the request as a
GET
with the variables in the URL, this is no good. - 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
- Thank you to you Sonnie Hiles for the flexible woman image found on Unsplash