GraphQL is for your product
There's a big difference between building with GraphQL and older style REST endpoints: GraphQL gives consumers the power to shape the data they receive. This is a huge shift from largely static REST response types, and needs to be kept in mind as you design your graph architecture.
The access patterns enabled by GraphQL's query language simply don't have equivalents in RESTful architectures. I've seen a lot of teams struggle with making the switch, and it always causes pain down the road as the GraphQL schema becomes increasingly unwieldy to evolve and consume.
When you design a REST endpoint you need to strike a balance with your response payloads. Including too much information results in increased load on your systems and slower response times for consumers, while not including enough information means clients have to make multiple round trips and eat higher latencies. As a consequence, REST APIs have a tendency to mirror the underlying data model pretty closely.
This balancing act doesn't exist with GraphQL. You're free to add as many fields to your schema types as your heart desires, because you aren't forced to send every single field to every single consumer like you are with REST. Leveraging this capability means you can do things like centralizing important business logic right into your graph.
If you take only one thing away from this post it is that GraphQL is best thought of as an abstraction layer between your data and your product. The goal of your graph's schema should be to translate your raw data into a format that's readily usable by your product. The product is the driving force for how your schema ends up looking–not your underlying database technologies.
Don't mirror your backend models
Similar to the previous section, your backend models almost never represent the data that your consumer is actually interested in. Consider the following collection of models appearing in a social media app which represent a many:many relationship between users and groups:
interface User { id: ID; // ...} interface UserGroup { userId: ID; groupId: ID; status: UserGroupStatus; // ...} interface Group { id: ID; // ...}
On a user's profile we may want to display a list of groups they're a member of. There are a lot of different ways to design a REST API for this, including:
- Exposing some sort of
getUserGroups
endpoint, which retrieves theUserGroup
join table records for a given user. From there, consumers can fetch the associated groups. - Including a
groups
orgroupIds
field inside the response to a hypotheticalgetUser
endpoint. Consumers can then use that field to look up the actual group objects. - If we're adopting a backend for frontend approach, we might write a one-off
getUserGroupsList
endpoint which takes in a user's ID and returns their groups. - Some sort of
searchGroups
endpoint which can take in a filter specifying user IDs. The endpoint would return groups with all of those specified user(s) inside. - And many more!
While these approaches are all workable, none of them are particularly good. The first option is particularly nasty because it leaks the existence of the UserGroup
join table to the consumer and how users' group memberships are stored inside the database is really none of the consumer's business. With a SQL database we're likely to use a join table, but with a NoSQL database we'd likely include the group IDs as a field directly on the user record. Our API types shouldn't be informed by our choice of database.
Option 2 is a bit better and avoids us needing to leak the existence of our join table, but it's easy to see how inlining fields such as groups
or groupIds
won't scale forever. We can't indefinitely add new arrays to our response type because every new field means at least one extra database join, and an increase in bandwidth consumption.
If you're all in on BFF, then option 3 isn't so terrible in a vacuum. The typical caveats about BFF apply here.
Option 4 is also okay, but at this point we're now treading into dangerous territory. If we're intending to show the user's groups on their profile then we're going to need to hit both the getUserById
and searchGroups
endpoints in order to get all of our needed data. Two requests running in parallel where one might have sufficed won't nuke your app's performance on their own, but the problem is that situations like this never remain at only two requests. Someone inevitably comes along and adds more data fetching to the mix.
How might we model this in GraphQL?
type User { id: ID! groupsConnection: UserGroupsConnection} type UserGroupsConnection { edges: [UserGroupsEdge] nodes: [Group]} type UserGroupsEdge { cursor: String! node: Group status: UserGroupStatus}
This schema has a groupsConnection
field on the User
type, which is modeled roughly according to the GraphQL cursor connections specification, but omits pagination to keep things slim. Cursor connections are a robust way of modeling links between resources in your graph, and if you'd like to learn more about this scheme I recommend Andrew Ingram's post on the topic.
This GraphQL schema has a few major advantages over the various RESTful approaches, and also avoids falling into REST-isms. Namely:
- None of our types include foreign key fields. In GraphQL we don't fetch things from a foreign key–we retrieve related objects by simply traversing the graph. How things connect to each other (such as the direction of the foreign key relationship) is an implementation detail.
- We don't leak the existence of our join table. It's true that our
UserGroupsEdge
includes data from theUserGroup
table, but connections are a generic and reusable abstraction over related entities. We could in theory omit thestatus
field from the edge type if we felt it wasn't useful to the consumer and still be consistent with the cursor connection spec. - Following on from the above, the relationship data between our user and their groups is correctly implemented as an ephemeral wrapper around the group. While not enforced by all GraphQL clients, the
id
field is special and typically means that the object it's on is some sort of discrete resource within your API1. In reality, however, theUserGroup
information is completely meaningless absent of the user and the group. It's not really something we should consider to be a resource–it's just a bit of metadata attached to the relationship between two of our "actual" resources. - Because we're using GraphQL, we can enable extremely flexible access patterns. By including both
edges
andnodes
on ourUserGroupsConnection
type we enable consumers to choose whether or not they care about theUserGroup
metadata.
Encode your business logic into the graph
As GraphQL gives API consumers the power to decide what data they receive, putting expensive fields on your types is generally okay to do. Those expensive to calculate fields don't run on every single request like they would in a REST endpoint and with something like graphql-query-cost
it's possible to guard against malicious queries.
Following on from the example we considered earlier, how might we go about determining whether a user is a member of a group? One strategy is to fetch the user and their groups, and then loop over the groups looking for a match. Something like the following would work:
const isUserInGroup = async ( userId: string, groupId: string,): Promise<boolean> => { const data = await fetchGraphQL(` query GetUserAndGroups { user(id: "${userId}") { groupsConnection { nodes { id } } } } `); for (const node of data.user.groupsConnection.nodes) { if (node.id === groupId) { return true; } } return false;}
This works and the implementation is straightforward, but it's easy to see how this can become unwieldy over time. It's extremely unlikely that we're only ever going to need to perform this check in one location, and duplicated business logic sprawling throughout your company's codebases makes evolution of the schema harder down the line.
It's actually pretty reasonable to just add a field like inGroup(id: ID!): Boolean
to our User
type.
const isUserInGroup = async ( userId: string, groupId: string,): Promise<boolean> => { const data = await fetchGraphQL(` query GetUserInGroup { user(id: "${userId}") { inGroup(id: "${groupId}") } } `); return data.user.inGroup;}
Our consumer is now far slimmer, and the business logic for determining group membership is now centralized within our graph. If we were to make significant changes down the line to how group memberships work, we can simply update inGroup
to reflect our new business requirements and rest easy with the knowledge that our consumers will continue to run smoothly.
Only have one entry point to your graph
In a RESTful paradigm it's not so bad to have a bunch of microservices which you access via different URLs as there's no good way to "stitch" REST APIs together. Oftentimes you'll just have an API gateway in front of your microservices and route different subdomains or path /prefix
es to different services, and treat them all as completely separate components in your architecture.
It's possible to do this with GraphQL–no one is stopping you from spinning up a bunch of different services which each advertise their own independent graph schema. If you do decide to venture down this path, however, you'll quickly find that it really sucks for two reasons:
- One of GraphQL's big selling points is its ability to eliminate waterfalls by enabling consumers to fetch all of the data they need in one request. The moment your client starts querying against two separate servers, you've completely thrown out that benefit. Consider independent "User API" and "Group API" services–we're no longer able to implement our
groupsConnection
field onUser
. - Having different entry points into the graph means that your consumer needs to have some knowledge about your service topoology. Similar to join tables, your consumer really shouldn't need to know how you've laid out your backend in order to fetch data. It's irrelevant whether your
User
record is in service A or service B once you step outside of the backend, and requiring consumers to know this is a particularly tight form of coupling.
Unlike REST, GraphQL has something called schema stitching which allows you to unify different services under one access point. You can still have an independent User API and Group API each advertising their own GraphQL schemas if that's what works best for your team, but instead of having consumers connect directly to those services you'll instead have them connect to an aggregation service at the edge of your infrastructure.
The aggregation service is responsible for fetching schemas from the underlying services and stitching them together. In this approach the groupsConnection
field likely ends up getting implemented either inside the aggregation service itself, or via a dedicated getUserGroupsList
endpoint on the Group API that's kept private from external consumers.
Stitching is really powerful, and completely decouples your consumer-facing graph from the underlying topology of your backend services. It's possible to start out with a monolith and then progressively split out services as you need to scale without making any change at all to your API consumers and without needing to maintain deprecated stub REST endpoints which simply proxy through to the newly separated service.
You should (almost) always put up a gateway in front of your GraphQL microservices which performs schema stitching. Even if you only have one service today (like an efficient startup should), it's worth getting this infrastructure in place. It's simple to set up and positions you to evolve your service topology down the line in the event your company strikes gold and scales to the point where a more service-oriented architecture starts to make sense.
- The Global Object Identification specification revolves around this idea.↩