as your schema grows, it is appealing to reuse types across different fields and use cases
in some cases this makes sense; however, trying to share too much rarely works out well
an example of how this can turn troublesome is related to the connection pattern (as discussed in graphql schema design pt 2)
here, we have a schema where an Organization
has a paginated connection of users
type UserConnection {
edges: [UserEdge!]!
pageInfo: PageInfo!
}
type UserEdge {
node: User
}
type User {
login: String!
}
type Organization {
users: UserConnection!
}
and now lets say that the concept of teams and team members’ comes into play
a mistake would be to reuse the same UserConnection
for the members
field
after all, team members and organization users are all users
type UserConnection {
edges: [UserEdge!]!
pageInfo: PageInfo!
}
type UserEdge {
node: User
}
type User {
login: String!
}
type Organization {
users: UserConnection!
teams: [Team]
}
type Team {
members: UserConnection!
}
an issue with reusing types like this is the likelihood of these types diverging over time is high
moving forward it can get easy to get stuck if (or when) new features are introduced
type UserEdge {
isTeamLeader: Boolean
isOrganizationAdmin: Boolean
}
the UserEdge
(where information should be related to a user within a team) is shared across organization users and team users; therefore, you can no longer add anything specific to one of them
imagine if you had a TeamMemberConnection
and a OrganizationUserConnection
you would be free to encode any data on the edges and connections, which could very likely turn out problematic
another common misstep is to share inputs
in a lot of cases, create
and update
mutations look similar; the update
mutation will take the id
of the resource to update and most of the attributes will stay the same
input ProductInput {
name: String
price: MoneyInput
}
type Mutation {
createProduct(input: ProductInput): CreateProductPayload
updateProduct(id: ID!, input: ProductInput): UpdateProductPayload
}
this can be useful to allow generated clients to reuse forms and logic; however, it can lead to similar problems as we discussed above
usually, a create input would have more non-null fields (you can not create a product without a name
for example)
because it is reused in the update mutation, that input field has to be made nullable (the creation mutation would have to handle validation at runtime instead of letting the schema handle it)
generally, you should avoid trying too hard to share types unless it is very obvious it can be shared
if you have doubts, keep in mind that the downsides usually outweigh the benefits
a concept that has gained a lot of popularity amongst graphql APIs is global identification of objects
the idea behind this is that a graphql client should be able to fetch any node in the graph given a unique identifier
practically speaking, this translates in a graphql server exposing a global node (id: ID!): Node
field that lets clients fetch any node through that single field
the node
field returns a Node
interface type
interface Node {
id: ID!
}
type User implements Node {
id: ID!
name: String!
}
the Node
interface is essentially an object with a globally unique ID and can be fetched using the node(id: ID!)
and nodes(ids: [ID!]!)
fields
the purpose of global identification is client-side caching
do you absolutely need global identification? you might not if:
if you are using apollo, apollo can use a combination of the type name and a simpler ID to build a global identifier for you
having a globally unique ID for a certain node or object can be a useful principle,
global identifiers are similar to uniform resource identifiers (URIs) which are used to identify REST resources
users should not try to build or hack their IDs - but instead use the IDs they get from the API directly
to ensure this happens, you can use opaque identifiers
{
"data": {
"node": {
"id": "RmFjdGlvbjoy"
}
}
}
the most common way to make IDs opaque is to Base64
them, the idea is not to hide how the ID is built (it is easy for the client to see it is Base64
), this just serves as a reminder to the client that the string is meant to be opaque
opaque IDs are great because they enable you to change the underlying ID generation knowing that clients have (hopefully) not built logic coupled to how the ID is constructed
sometimes this does not lead to the best developer experience
it can be difficult to know what kind of node ID you have when you are building a client application
when you build these IDs, try and include as much information about them as possible to help fetch this node globally
a basic example might look something like this type_name:databse_id
, you should not default to this though
in some cases, nodes cannot be fetched without routing information (especially in more distributed architectures)
make sure you include any information that would make it easier to route to this node
example: products might be shared or distributed by shops (meaning our IDs might need the shop
id in there shop_id: type_name:id
)
a graphql concept that allows you to define whether a field can return
null
or not when queried
a non-null field (a field that cannot return null
at runtime is defined by using the !
syntax after its type)
by default all fields are nullable
type Product {
# this field is non-null
name: String!
# price returns null when the product is free (default)
price: Money
# the tags field itself cannot be null
# if it does return a list, then every
# item within this list is non-null
tags: [Tag!]
}
if a field returns null
when queried (even if it was marked as non-null in the schema) this will cause a graphql server to error
since the field cannot be null
graphql will go up to the parent of the field until it finds a nullable one
if all fields were all non-nullable the entire query returns null
with an error
below, the topProduct
field is marked as non-null and the shop
field is marked as nullable
query {
shop(id: 1) {
name
topProduct {
name
price
tags
}
}
}
imagine name
was to return null
even though you marked it as non-null, because topProduct
cannot be null either the result would look like this
{
"data": {
"shop": null
}
}
shop
was nullable; therefore, graphql wiped the response even though name
was the only field that was returning null
this is a good reminder that nullability can either be really powerful or a terrible mistake (depending on how it is applied)
non-nullability can be great:
non-nullability can be tricky as well:
a few guidelines for nullability:
null
and that will still allow for partial responses at the parent level (this can be hard to predict, especially when the type ends up being reused in other contexts)abstract types can be of great help when designing a schema and can be truly helpful to decouple your interface from the underlying persistence layer
pretend this type was generated from your database model
type SocialMediaFeedCard {
id: ID!
title: String!
birthdayDate: DateTime
eventDate: DateTime
postContent: String
}
this represents a social media post; however, there are some issues with this type
sometimes a post is about a birthday, sometimes it is about an event,and sometimes it is simply a text post
with the example above, you are not using the schema to the full potential and now there are opportunities to be in illegal states
a birthday card should not have content, simply a birthdayDate
an event should be not include a birthdayDate
, but as it stands now, the schema implies that this is possible
to address this, you can design your schema with the help of abstract types, in this case by using an Interface
type
interface SocialMediaFeedCard {
id: ID!
title: String!
}
type BirthdayFeedCard implements SocialMediaFeedCard {
id: ID!
title: String!
date: DateTime!
}
type EventFeedCard implements SocialMediaFeedCard {
id: ID!
title: String!
date: DateTime!
}
type ContentFeedCard implements SocialMediaFeedCard {
id: ID!
title: String!
content: String!
}
this schema is much clearer for clients to understand, and we can now understand the different types that might be coming our way
you also do not need nullable fields anymore and all potential types do not allow for any illegal states
graphql has two kinds of abstract types: union types and interface types
when should you use them?
interfaces
should be used when providing a common contract for things that share behaviors
unions
should be used when a certain field could return different types (but these types do not necessarily share common behaviors)
interfaces are great to create stronger contracts in your schema, but they are over-relied on sometimes (a common example is using them for common fields)
if multiple types share a few fields but do not share common behavior, avoid the temptation to throw an interface into the mix
a good interface should mean something to the API consumers; therefore, describing and providing a common way to do or behave a certain way
a good way to determine if your interfaces are relying too much on categorizing objects and grouping by similar attributes as opposed to interactions and behaviors is to check your naming of these interfaces in your schema
if you are using interfaces that do not have a strong meaning in the schema, the naming might be awkward and/or meaningless e.g. ItemInterface
this interface might share common fields initially, but will become difficult to maintain overtime as items evolve e.g. cart items, checkout items, and order items
abstract types give the impression that it will be easier to evolve your API over time and, in some cases that is true
e.g. if a field returns an interface type (and you follow Liskov’s substitution principle), adding a new object type which implements an interface should not cause client applications to behave differently
while this is true for interfaces, this is less true for unions
unions allow completely disjoint types
adding union members and interface implementations are not breaking changes in the strict sense (a client might not immediately break as if we removed a field definition); however, it is still tricky to make these changes in most cases and should be considered to be a dangerous change
graphql clients are not forced into selected all union possibilities or all concrete types on an interface
graphql clients should code defensively against new cases and graphql servers should be cautious of adding types that may affect important client logic
it is tempting to use SDKs, query builders, etc
e.g.
query.products(first: 10).fields(["name", "price"])
a better way is to define the graphql query like below
query {
products(first: 10) {
name
price
}
}
the example above is explicit and informs the client of what data is being asked for, as well as the shape of query will look like at runtime
you should strive to keep queries static
static query: a query that does not change based on any variable, condition, or state of the program
advantages:
query FetchProducts { products { name } }
dynamic example:
const productFields = products.map((id, index) => {
return `product${index}: product(id: "${id}") { name }`;
})
const query = `
query {
${productFields}.join('\n')
}
`
the example above has a list of product id
s and build a graphql query to fetch the product object associated with each id
at runtime the query would look like this
query {
product0: product(id: "abc") { name }
product1: product(id: "def") { name }
product2: product(id: "ghi") { name }
product3: product(id: "klm") { name }
}
the problem:
id
s are in the list at runtime (it uses field aliases product0
, product1
, etc to do so)we can avoid this and keep a consistent query by using variables
query FetchProducts($ids: [ID!]!) {
products(ids: $ids) {
name
price
}
}
by doing this, the query string never changes (but the client can fetch as many products as they want by supplying a different set of variables)
it is helpful to offer a plural version of most fields (and a way to fetch a single entity if needed as well)
clients can provide a single value to a list type argument in graphql
query {
# this is valid
products(ids: "abc") {
name
price
}
}