The purpose of this document is to establish standards for GraphQL services types ensuring consistency across our graph.
- PLA-008 GraphQL Conventions
- The Standards
- Other reading
ACCEPTED
None yet.
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.
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'}
.
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.
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
.
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
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.
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.
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
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 alimit
in the number of items, pagination etc.
This seperates "what to search" from "how to search".
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.
type FooPage {
items: [Foo!]!
}
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.
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]).
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.
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
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.
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!]!
}