Skip to content

Instantly share code, notes, and snippets.

@timReynolds
Created June 2, 2023 08:46
Show Gist options
  • Save timReynolds/900f952b329e9d7baa5c8ef3459c224d to your computer and use it in GitHub Desktop.
Save timReynolds/900f952b329e9d7baa5c8ef3459c224d to your computer and use it in GitHub Desktop.
GraphQL Conventions

GraphQL Conventions

Abstract

The purpose of this document is to establish standards for GraphQL services types ensuring consistency across our graph.

Table of contents

Status of this memo

ACCEPTED

Changes

None yet.

Introduction

GraphQL standards will help our team to both move faster by spending less time deliberaiting on minutia and following pre-existing standards, as well as protect ourselves from common mistakes that lead to sunk time in rectifying.

Much of the suggestions in this document are taken from official GraphQL specs - however by formalising this here we mark our agreement and adoption with/of these specs.

The Standards

Entity Identity

TL;DR: Entity identity should be implemented using id.

Throughout our codebase, we use id, _id, uid and uuid interchangeably to serve the same purpose. id and _id are identifiers; uid and uuid are implementations.

Henceforth, we will always use id, unless the specific situation calls for the consumer to have knowledge of the implementation of the string. However, in such a scenario, one might suggest passing an object akin to {id: 'asdf', format: 'uuid'}.

The ID Scalar

TL;DR: When returning any globally identifiable entity, use the ID scalar in place of String, implementing the Node interface.

This will enable us to move toward Global Object Identification by creating a root node field, as well as conforming with other aspects of the spec.

Referencing other objects

TL;DR: Foreign keys under barId, references under barRef, and always expose the linked entity.

If a return type holds an id to another entity, this should be returned under the barId key, i.e. userId.

If the reference requires further metadata, this and the id should be captued under barRef, i.e. clinicalProductRef: { id: 'foo', type: 'vmp' }.

In either case, the referenced entity should also be exposed on the return type. This way, when running as part of the federated graph, a consumer can step into the referenced entity to retrieve interested fields. i.e. in the first example, we have a userId, but we should also have a user: User.

Objects, always objects

TL;DR: All resolver input and return types should be objects.

All input parameters and return types to/from resolvers (IOs) should always be an object, even if either naturally promotes a different scalar (i.e. list, number).

As requirements change, resolvers too will need to. It is easy to rollout changes to IOs that are objects by creating/deprecting fields, all without downtime, handling the two different scenarios in the same resolver.

i.e. Changing a resolver's IOs from a list to an object, because we needed to support pagination and it had the perfect name, is exceedingly time consuming:

1. writing a new resolver 2. migrating all uses to new resolver 3. change old resolver to share same implementation as new resolver 4. migrate all uses back to the old resolver 5. delete the 'new' resolver

Queries

Resolver naming

TL;DR: foos not getFoos.

A query returning a list of entity Foo should be called foos and not getFoos or foosById, adhering to the plural identifying root fields best practice.

Pluralisation

TL;DR: Everything = Singular, except fields with a list return type and queries/mutations involving multiple entities.

If you're using a data type other than a list to convey many "things", consider changing the data type, or identifying the type, i.e. validUserMap: {user-1: true, user-2: false}. This leaves the key validUsers free to be used to return fully resolved User objects if later required.

List length and order matching

TL;DR: Any field containing a list of 1:1 matching elements to the input list, should always have the same length and order as the input.

When submitting a list of items as an input ([note][#objects-always-objects]), and expecting results for each, the result list should have the same length and order as the input. This enables the consumer to zip together the result with their input with ease. If results cannot be found for an index, a null is appropriate.

This becomes useful for Plural identifying root fields

Search

TL;DR: Two input objects params & options, with a paginated return type.

  • params: the fields upon which you are filtering.
  • options: (optional) meta-conditions of the search, such as a limit in the number of items, pagination etc.

This seperates "what to search" from "how to search".

Pagination

TL;DR: In most cases, return an object with a collection inside named items

GraphQL, on this page, recommends the spec from relay.dev. However we do not require the flexibility it provides. Thus, we have 2 options, which use different fields and thereby can easily be migrated between should requirements change.

The input should supply its offset by a cursor, such as the entity's id, but page number and size can be used if required.

Option 1: Simple

type FooPage {
  items: [Foo!]!
}

Option 2: Resilient and efficient

Following the spec from relay.dev, however, we will use the term Page in place of Connection.

To be used in specific scenarios, such as when the underlying data is likely to change fast and not skipping rows is important

The types should look as follows:

type FooEdge {
  node: Foo!
  cursor: ID!
}

type PageInfo {
  // anything you like such as total edges altogether, plus recommended fields.
}

type FooPage {
  edges: [FooEdge!]!
  pageInfo: PageInfo
}

If you're paginating via offset/skip, then cursor should be some encoding thereof.

Mutations

One input argument

TL;DR: All mutations should have only one input parameters, which is an object.

Following the recommended guidelines, this allows for the greatest flexibility, akin to ([the object section][#objects-always-objects]).

Highly specific > General

TL;DR: Specific mutations that correspond to semantic user actions are more powerful than general mutations.

Having a general mutation that can perform a lot of work makes it harder to reason about. Specific mutations are easier to reason about, easier to optimise, and safer from attack due to the smaller attack vector. Don't be afraid of a mutation for every UI interaction.

Resolver naming

TL;DR: verbNoun

Apollo recommends naming mutation resolvers verb-first. While crud-focused applications can suit being able to order by the entities, we should follow the recommendation to have consistency.

Our vocabulary will be:

  • Creations: createFoo/createFoos
  • Reads: foo/foos (see above)
  • Updates: updateFoo/updateFoos
  • Deletions: deleteFoo/deleteFoos

Propagate errors

TL;DR: When mutations fail, we should propagate these as errors

Avoid uses of fields like success: Boolean! in a response, and instead propagate errors through the gateway.

When performing mutations on multiple entities at once, this should be transactional and complete as a whole or not at all. This allows retry logic, cache invalidation, etc. in the caller to be simplified. Which makes it easier to understand and thereby bugs less likely.

Should you be unable to perform all actions transactionally, use the following, where null indicates a failure. Only use this if you must:

type FooResponse {
  items: [Foo]!
}

List ordering must be adhered to in order for the caller to link entities to failures, but should always be adhered to regardless.

Multi-entity action return types

TL;DR: Always return an object with a list of objects, each with at least an id

Mutations involving multiple entities must always adhere to list ordering.

For CRU mutations upon multiple entities, i.e. updateFoos the response should always be an object with a list holding the new values of the entities.

For D(elete), this should always be a list of objects that at least contain an ID. Whether the implementation wishes to fetch all the objects before they are deleted and supply them in full in the response, is up to the use-case. E.g. for the former:

interface IdentifiableEntity {
  id: ID!
}

type DeleteFooResponse {
  deleted: [IdentifiableEntities!]!
}

Other reading

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment