Relay

Note: Even though they originated in Relay, the design principles described in this document are not exclusive to Relay. They lead to an overall better schema design, which is why we recommend them to all users of Hot Chocolate.

Relay is a JavaScript framework for building data-driven React applications with GraphQL, which is developed and used by Facebook.

As part of a specification Relay proposes some schema design principles for GraphQL servers in order to more efficiently fetch, refetch and cache entities on the client. In order to get the most performance out of Relay our GraphQL server needs to abide by these principles.

Learn more about the Relay GraphQL Server Specification

Global identifiers

If an output type contains an id: ID! field, Relay and other GraphQL clients will consider this the unique identifier of the entity and might use it to construct a flat cache. This can be problematic, since we could have the same identifier for two of our types. When using SQL for example, a Foo and Bar type could both contain a row with the identifier 1 in their respective tables.

We could switch to a database technology that uses unique identifiers across tables/collections, but as soon as we introduce a different data source, we might be facing the same problem again.

Fortunately there is an easier, more integrated way to go about solving this problem in Hot Chocolate: Global identifiers.

With Global Identifiers, Hot Chocolate adds a middleware that automatically serializes our identifiers to be unique within the schema. The concern of globally unique identifiers is therefore kept separate from our business domain and we can continue using the "real" identifiers within our business code, without worrying about uniqueness for a client.

Usage in Output Types

When returning an Id in an output type, Hot Chocolate can automatically combine its value with another value to form a unique Id. Per default this additional value is the name of the type the Id belongs to. Since type names are unique within a schema, this ensures that we are returning a unique Id within the schema. If our GraphQL server serves multiple schemas, the schema name is also included in this combined Id. The resulting Id is then Base64 encoded to make it opaque.

We can opt Ids into this behavior like the following.

C#
public class Product
{
[ID]
public int Id { get; set; }
}

If no arguments are passed to the [ID] attribute, it will use the name of the output type, in this case Product, to serialize the Id.

If we do not want to use the name of the output type, we can specify a string of our choice.

C#
[ID("Foo")]
public int Id { get; set; }

The type of fields specified as ID is also automatically switched to the ID scalar.

Learn more about the ID scalar

Usage in Input Types

If our Product output type returns a serialized Id, all arguments and fields on input object types, accepting a Product Id, need to be able to interpret the serialized Id. Therefore we also need to define them as ID, in order to deserialize the serialized Id to the actual Id.

C#
public class Query
{
public Product GetProduct([ID] int id)
{
// Omitted code for brevity
}
}

In input object types we can use the [ID] attribute on specific fields.

C#
public class ProductInput
{
[ID]
public int ProductId { get; set; }
}

Per default all serialized Ids are accepted. If we want to only accept Ids that have been serialized for the Product output type, we can specify the type name as argument to the [ID] attribute.

C#
public Product GetProduct([ID(nameof(Product))] int id)

This will result in an error if an Id, serialized using a different type name than Product, is used as input.

Id Serializer

Unique (or global) Ids are generated using the IIdSerializer. We can access it like any other service and use it to serialize or deserialize global Ids ourselves.

C#
public class Query
{
public string Example([Service] IIdSerializer serializer)
{
string serializedId = serializer.Serialize(null, "Product", "123");
IdValue deserializedIdValue = serializer.Deserialize(serializedId);
object deserializedId = deserializedIdValue.Value;
// Omitted code for brevity
}
}

The Serialize() method takes the schema name as a first argument, followed by the type name and lastly the actual Id.

Learn more about accessing services

Global Object Identification

Global Object Identification, as the name suggests, is about being able to uniquely identify an object within our schema. Moreover, it allows consumers of our schema to refetch an object in a standardized way. This capability allows client applications, such as Relay, to automatically refetch types.

To identify types that can be refetched, a new Node interface type is introduced.

SDL
interface Node {
id: ID!
}

Implementing this type signals to client applications, that the implementing type can be refetched. Implementing it also enforces the existence of an id field, a unique identifier, needed for the refetch operation.

To refetch the types implementing the Node interface, a new node field is added to the query.

SDL
type Query {
node(id: ID!): Node
}

While it is not part of the specification, Hot Chocolate also adds a nodes field allowing you to refetch multiple objects in one round trip.

SDL
type Query {
node(id: ID!): Node
nodes(ids: [ID!]!): [Node]!
}

Usage

In Hot Chocolate we can enable Global Object Identification, by calling AddGlobalObjectIdentification() on the IRequestExecutorBuilder.

C#
public class Startup
{
public void ConfigureServices(IServiceCollection services)
{
services
.AddGraphQLServer()
.AddGlobalObjectIdentification()
.AddQueryType<Query>();
}
}

This registers the Node interface type and adds the node(id: ID!): Node and the nodes(ids: [ID!]!): [Node]! field to our query type. At least one type in our schema needs to implement the Node interface or an exception is raised.

⚠️ Note: Using AddGlobalObjectIdentification() in two upstream stitched services does currently not work out of the box.

Next we need to extend our object types with the Global Object Identification functionality. Therefore 3 cirteria need to be fulfilled:

  1. The type needs to implement the Node interface.
  2. On the type an id field needs to be present to properly implement the contract of the Node interface.
  3. A method responsible for refetching an object based on its id needs to be defined.

To declare an object type as a refetchable, we need to annotate it using the [Node] attribute. This in turn causes the type to implement the Node interface and if present automatically turns the id field into a global identifier.

There also needs to be a method, a node resolver, responsible for the acutal refetching of the object. Assuming our class is called Product, Hot Chocolate looks for a static method, with one of the following names:

  • Get
  • GetAsync
  • GetProduct
  • GetProductAsync

The method is expected to have a return type of either Product or Task<Product>. Furthermore the first argument of this method is expected to be of the same type as the Id property. At runtime Hot Chocolate will invoke this method with the id of the object that should be refetched. Special types, such as services, can be injected as arguments as well.

C#
[Node]
public class Product
{
public string Id { get; set; }
public static async Task<Product> Get(string id,
[Service] ProductService service)
{
Product product = await service.GetByIdAsync(id);
return product;
}
}

If we need to influence the global identifier generation, we can annotate the Id property manually.

C#
[ID("Example")]
public string Id { get; set; }

If the Id property of our class is not called id, we can either rename it or specify the name of the property that should be the id field through the [Node] attribute. Hot Chocolate will then automatically rename this property to id in the schema to properly implement the contract of the Node interface.

C#
[Node(IdField = nameof(ProductId))]
public class Product
{
public string ProductId { get; set; }
// Omitted code for brevity
}

If our node resolver method doesn't follow the naming conventions laid out above, we can annotate it using the [NodeResolver] attribute to let Hot Chocolate know that this should be the method used for refetching the object.

C#
[NodeResolver]
public static Product OtherMethod(string id)
{
// Omitted code for brevity
}

In case we want to resolve the object using another class, we can reference the class / method like the following.

C#
[Node(NodeResolverType = typeof(ProductNodeResolver),
NodeResolver = nameof(ProductNodeResolver.MethodName))]
public class Product
{
public string ProductId { get; set; }
}
public class ProductNodeResolver
{
public static Product MethodName(string id)
{
// Omitted code for brevity
}
}

When wanting to place the Node functionality in an extension type, it is important to keep in mind that the [Node] attribute needs to be defined on the class extending the original type.

C#
[Node]
[ExtendObjectType(typeof(Product))]
public class ProductExtensions
{
public Product GetProductAsync(string id)
{
// Omitted code for brevity
}
}

Learn more about extending types

Since node resolvers resolve entities by their Id, they are the perfect place to start utilizing DataLoaders.

Learn more about DataLoaders

Connections

Connections are a standardized way to expose pagination capabilities.

SDL
type Query {
users(first: Int after: String last: Int before: String): UserConnection
}
type UserConnection {
pageInfo: PageInfo!
edges: [UserEdge!]
nodes: [User!]
}
type UserEdge {
cursor: String!
node: User!
}
type PageInfo {
hasNextPage: Boolean!
hasPreviousPage: Boolean!
startCursor: String
endCursor: String
}

Learn more about Connections

Query field in Mutation payloads

It's a common best practice to return a payload type from mutations containing the affected entity as a field.

SDL
type Mutation {
likePost(id: ID!): LikePostPayload
}
type LikePostPayload {
post: Post
}

This allows us to immediately process the affected entity in the client application responsible for the mutation.

Sometimes a mutation might also affect other parts of our application as well. Maybe the likePost mutation needs to update an Activity Feed.

For this scenario we can expose a query field on our payload type to allow the client application to fetch everything it needs to update its state in one round trip.

SDL
type LikePostPayload {
post: Post
query: Query
}

A resulting mutation request could look like the following.

GraphQL
mutation {
likePost(id: 1) {
post {
id
content
likes
}
query {
...ActivityFeed_Fragment
}
}
}

Usage

Hot Chocolate allows us to automatically add this query field to all of our mutation payload types:

C#
public class Startup
{
public void ConfigureServices(IServiceCollection services)
{
services
.AddGraphQLServer()
.AddQueryFieldToMutationPayloads();
}
}

By default, this will add a field of type Query called query to each top-level mutation field type, whose name ends in Payload.

Of course these defaults can be tweaked:

C#
services
.AddGraphQLServer()
.AddQueryFieldToMutationPayloads(options =>
{
options.QueryFieldName = "rootQuery";
options.MutationPayloadPredicate =
(type) => type.Name.Value.EndsWith("Result");
});

This would add a field of type Query with the name of rootQuery to each top-level mutation field type, whose name ends in Result.