The Node Interface
You're encouraged to read up on the Node interface before reading this text. Here's a general article on the Node interface, and here's the relevant best practices section from the GraphQL documentation.
If you want to unlock the true power of GraphQL clients like Relay, you're strongly encouraged to implement what's called the Node interface.
Node interface crash course
The Node interface is essentially a convention that lets each node in the graph:
- Have an
id
that's unique in your entire graph, not just among the type the node is of. - Be possible to refetch each node by itself only via its
id
. Meaning it needs to be possible to destructid
into something that reveals both the actualid
, and what type it's for, so we know what type to fetch.
Together, these two points enable clients like Relay to:
- Do normalized caching effortlessly, which brings all sorts of goodies like data consistency, automatic cache updates and so on.
- Automatically build queries for pagination and refetching, so you don't have to write those queries yourself.
In a nutshell, the Node interface looks and works something like this:
# The schema
# Defines the Node interface
interface Node {
id: ID!
}
# Each type implementing Node should have a globally unique ID
type User implements Node {
id: ID!
name: String!
}
# `Query` should have a top level field called `node` that lets you refetch any node in the graph by its id
type Query {
node(id: ID!): Node
}
Now, imagine we've got an id
for a User
from somewhere in our graph. Without having to care about where that User
came from, we can fetch data from that exact user via the Node interface:
query NodeTestQuery($userId: ID!) {
node(id: $userId) {
... on User {
name
}
}
}
This would return data from User
if the value we pass to userId
is indeed an id
coming from a User
.
The Node interface in ResGraph
Now, all of the above sounds great. But it's up to you to implement all of that in practice. From our long experience of building GraphQL servers we know that this can feel and be much trickier than it needs to be. So, let us walk you through how easy implementing the Node interface is in ResGraph.
In a nutshell, what we need to do is the following:
- Define our
Node
interface type. - Expose a field called
id
from that interface. Thatid
needs to be anID
in GraphQL, and needs to resolve as a globally unique ID for each type that implements it. - Expose a
node
field onQuery
that takes anid
and can resolve each of the types that implement theNode
interface.
Let's get started!
1. Defining the Node interface
Start by defining the Node interface. The Node interface is a convention, so it's important that it's named node
, even though there's nothing actually magical about that name:
// NodeInterface.res
@gql.interface
type node = {
id: string
}
Notice we don't expose id
as a GraphQL field directly, nor do we type it as ResGraph.id
. More on why in the next step.
2. Implementing the globally unique id
field for each type
Now we need to make sure that each and every type that implements Node
has an id
field that returns a globally unique id. We'll do this step by step. Let's start by defining a field id
for the interface Node
:
// NodeInterfaceResolvers.res
@gql.field
let id = (node: NodeInterface.node) => {
node.id->ResGraph.id
}
This adds an id: ID!
field to all types that implement Node
. Great! But this is just returning the id
coming from the type itself, so it's not going to be globally unique by default.
One "naive" idea would be to have a separate id
field function for each type to produce the globally unique ID:
@gql.field
let id = (user: user) => {
`User:${user.id}`->ResGraph.id
}
But that will get old fast as we add new types and need to add new field functions just for the ID every time.
Instead, we can leverage accessing what type the interface field function is currently working on. That, together with helpers that ResGraph generates for us, means we can build our generic node id
field in a way that'll work for all types that implement it automatically, without having to roll separate field functions for each type:
// NodeInterfaceResolvers.res
@gql.field
let id = (node: NodeInterface.node, ~typename: Interface_node.ImplementedBy.t) => {
`${typename->Interface_node.ImplementedBy.toString}:${node.id}`->ResGraph.id
}
There! Now every id
field of every type that implements Node
will be globally unique, and in the form of <type>:<id>
. So, for a user with the ID 123
, the globally unique ID will automatically be generated as User:123
.
Expose a node
field on Query
The final thing we need to do before we have a fully working Node interface setup is to implement the node
field on Query
, that takes any id
from a node and resolves its type.
Fortunately, ResGraph generates helpers for this task as well. Let's look at how an implementation of the node
field can look:
/** Fetches an object given its ID.*/
@gql.field
let node = async (_: Query.query, ~id, ~ctx: ResGraphContext.context): option<
Interface_node.Resolver.t,
> => {
switch id->ResGraph.idToString->String.split(":") {
| [typenameAsString, id] =>
switch typenameAsString->Interface_node.ImplementedBy.decode {
| None => None
| Some(User) =>
switch await ctx.dataLoaders.userById.load(~userId=id) {
| Ok(user) => Some(User(user))
| Error(_) => None
}
| Some(Group) =>
switch await ctx.dataLoaders.groupById.load(~userId=id) {
| Ok(group) => Some(Group(group))
| Error(_) => None
}
}
| _ => None
}
}
Lots of things to distill here.
- We annotate the return type of our field function as
Interface_node.Resolver.t
. This type is autogenerated, and is what ensures that you can return only types that implementNode
. It also informs ResGraph that it's indeed the interfaceNode
you're returning from this field. - Remember that we formatted our IDs like
<type>:<id>
. Because of that, we start by decoding the raw id passed tonode
by splitting theid
on:
to extract our type and real id. - We then use
Interface_node.ImplementedBy.decode
to decode that string into aInterface_node.ImplementedBy.t
type. These are both autogenerated by ResGraph, and will stay up to date with any types that start (or stop) implementing the Node interface.Interface_node.ImplementedBy.t
is a variant listing all typenames that implementNode
, andInterface_node.ImplementedBy.decode
is a function that decodes a string into aInterface_node.ImplementedBy.t
. - Now we have a type, and an id. We can move on to resolving the correct nodes.
There! Now we have a node
field that can resolve any node implementing the Node
interface by itself.
Advanced patterns
So far we've done an as simple implementation of our IDs as possible. However, there are several more things you might want to consider. In this section we'll discuss those considerations a bit.
Obfuscating the id
One thing you can consider is to "obfuscate" the id
so it doesn't read <type>:<id>
if you inspect it at runtime. Doing this can be for several reasons:
id
should be considered opaque, as in the client should not be tempted to rely on its implementation, because the server owns it and should be allowed to change it at any time. Obfuscating theid
so it's not directly human readable is a simple way to communicate to the client that "this is intended to be opaque to you".- You might want to stick the
id
into links and URL:s, meaning they'll need to be URL safe and generally look like an id you're not tempted to tamper with in the URL.
The most common way in GraphQL to handle this is to encode the id
as (URL safe) base64. Let's look at how you can do that easily with ResGraph.
Base64 encoding id
You can easily base64 encode and decode your id
by using ResGraph.Utils.Base64.encode/decode
. Let's change our examples to do that:
// NodeInterfaceResolvers.res
@gql.field
let id = (
node: NodeInterface.node,
~typename: Interface_node.ImplementedBy.t,
) => {
`${typename->Interface_node.ImplementedBy.toString}:${node.id}`
->ResGraph.Utils.Base64.encode
->ResGraph.id
}
Here we're encoding the id as base64. Now, instead of a User
with the id 123
having the id
User:123
, you'll instead see VXNlcjoxMjM,
.
Let's adapt the node
field function to handle that IDs are now base64 encoded:
/** Fetches an object given its ID.*/
@gql.field
let node = async (_: Query.query, ~id, ~ctx: ResGraphContext.context): option<
Interface_node.Resolver.t,
> => {
switch id
->ResGraph.idToString
->ResGraph.Utils.Base64.decode
->String.split(":") {
| [typenameAsString, id] =>
Rest of the code omitted for brevity.
Here we're decoding from base64 before we split it and inspect the parts.
There! That's all it takes for a simple obfuscation of the id
field.
Advanced: further compressing id
IDs tend to get quite large. This is because they're comprised of type names, which can be fairly verbose, and then the actual underlying id, which also can be large or verbose.
For the latter part there's not much else you can do than employing regular compression techniques. And in cases where you use UUIDs or other non-compressable formats, that won't help either.
However, we can compress our type names easily. Because we always know the full set of possible type names, we can create a mapping between a type name and something drastically smaller, like an int
. This would allow us to for example encode 1
or 2
into our IDs, instead of User
or Group
.
Fortunately ResGraph generates helpers for you to make this easy and durable.
Compressing type names with Interface_node.TypeMap
ResGraph will automatically generate assets that will help you translate between type names, and a mapping of your choice. Let's look at an example of how we can compress our type names in our id
easily leveraging that:
let typeMap: Interface_node.typeMap<int> = {
group: 1,
user: 2,
}
let nodeTypeMap = typeMap->Interface_node.TypeMap.make(~valueToString=Int.toString)
A few things to distill.
typeMap<'value>
is generated, and is a record type that expects you to fill in all types implementing the Node interface, and what'value
you want to map them to. Here we're mapping toint
.- We create a map of typenames to integers using that type. Since this is autogenerated it'll always be up to date, so any time a new type implements the Node interface you'll be prompted to fill its corresponding integer here.
- We then call
TypeMap.make
with our type map, and also pass it a~valueToString
function that shows how to turn'value
into a string.
Now we have a TypeMap.t
that we can leverage for encoding and decoding the type names in our id
. Let's update our examples:
// NodeInterfaceResolvers.res
let typeMap: Interface_node.typeMap<int> = {
group: 1,
user: 2,
}
let nodeTypeMap =
typeMap->Interface_node.TypeMap.make(~valueToString=Int.toString)
@gql.field
let id = (node: NodeInterface.node, ~typename: Interface_node.ImplementedBy.t) => {
let typenameAsString =
nodeTypeMap->Interface_node.TypeMap.getStringifiedValueByType(typename)
`${typenameAsString}:${node.id}`->ResGraph.Utils.Base64.encode->ResGraph.id
}
Now, instead of User:123
or VXNlcjoxMjM,
, you'll instead see MjoxMjM,
, a ~30% smaller id
. Here, the gain is a bit modest, but it increases with types that have longer names.
Let's show how we can leverage Interface_node.TypeMap
as we decode IDs in the node
field function:
/** Fetches an object given its ID.*/
@gql.field
let node = async (_: Query.query, ~id, ~ctx: ResGraphContext.context): option<
Interface_node.Resolver.t,
> => {
switch id->ResGraph.idToString->String.split(":") {
| [compressedTypename, id] =>
switch nodeTypeMap->Interface_node.TypeMap.getTypeByStringifiedValue(compressedTypename) {
| None => None
| Some(User) =>
switch await ctx.dataLoaders.userById.load(~userId=id) {
| Ok(user) => Some(User(user))
| Error(_) => None
}
| Some(Group) =>
switch await ctx.dataLoaders.groupById.load(~userId=id) {
| Ok(group) => Some(Group(group))
| Error(_) => None
}
}
| _ => None
}
}
We're now using Interface_node.TypeMap.getTypeByStringifiedValue
to go directly from a compressed string value in the id
(like "1"
for the integer 1
representing User
) to its underlying type, if it can be mapped.
And that's all you need to do to leverage this extra compression.
Wrapping up
To better examplify how much space this can save, imagine we have a type OrganizationConfigurationSettings
. Encoding that type with an id of 123
gives you T3JnYW5pemF0aW9uQ29uZmlndXJhdGlvbjoxMjM,
. But if that same type would be encoded as 3
, you'd instead get MzoxMjM,
, an 80% reduction.