Relay

The Relay GraphQL Server Specification defines patterns for globally unique identifiers, object refetching, and cursor-based pagination. While these patterns originated in Facebook's Relay client, they improve schema design for any GraphQL client.

Note: The patterns on this page benefit all GraphQL clients, not only Relay. We recommend them for every Hot Chocolate project.

Global Identifiers

GraphQL clients often use the id field to build a client-side cache. If two different types both have a row with id: 1, the cache encounters collisions. Global identifiers solve this by encoding the type name and the underlying ID into an opaque, Base64-encoded string that is unique across the entire schema.

Hot Chocolate handles this through a middleware. The [ID] attribute opts a field into global identifier behavior. At runtime, Hot Chocolate combines the type name with the raw ID to produce a globally unique value. Your business code continues to work with the original ID.

Output Fields

C#
// Types/Product.cs
public class Product
{
[ID]
public int Id { get; set; }
public string Name { get; set; }
}

The [ID] attribute rewrites the field type to ID! and serializes the value as a global identifier. By default, it uses the owning type name (Product) for serialization.

For foreign key fields that reference another type, specify the target type name:

C#
// Types/OrderItem.cs
public class OrderItem
{
[ID]
public int Id { get; set; }
[ID<Product>]
public int ProductId { get; set; }
}

In v16, the generic [ID<Product>] form infers the GraphQL type name from the type argument. You can also use [ID("Product")] to specify it as a string.

Input Arguments

When a field returns a serialized global ID, any argument that accepts that ID must also be marked with [ID] to deserialize it back to the raw value.

C#
// Types/ProductQueries.cs
[QueryType]
public static partial class ProductQueries
{
public static Product? GetProduct(
[ID] int id,
CatalogContext db)
=> db.Products.Find(id);
}

To restrict the argument to IDs serialized for a specific type:

C#
public static Product? GetProduct(
[ID<Product>] int id,
CatalogContext db)
=> db.Products.Find(id);

This rejects IDs that were serialized for a different type.

Input Object Fields

Mark input object properties with [ID] to deserialize global IDs in input types.

C#
// Types/UpdateProductInput.cs
public class UpdateProductInput
{
[ID]
public int ProductId { get; set; }
public string Name { get; set; }
}

ID Serializer

You can access the IIdSerializer service directly to serialize or deserialize global IDs in custom code.

C#
// Types/ProductQueries.cs
[QueryType]
public static partial class ProductQueries
{
public static string GetGlobalId(int productId, IIdSerializer serializer)
{
return serializer.Serialize(null, "Product", productId);
}
}

The Serialize method takes the schema name (or null for the default schema), the type name, and the raw ID.

Global Object Identification

Global object identification extends global identifiers by enabling clients to refetch any object by its ID through a standardized node query field. This requires three things:

  1. The type implements the Node interface.
  2. The type has an id: ID! field.
  3. A node resolver method can fetch the object by its ID.

Enabling Global Object Identification

C#
// Program.cs
builder
.AddGraphQL()
.AddGlobalObjectIdentification();

This adds the Node interface and the node / nodes query fields:

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

You can configure options when enabling global object identification:

C#
builder
.AddGraphQL()
.AddGlobalObjectIdentification(opts =>
{
opts.MaxAllowedNodeBatchSize = 50;
});

At least one type in the schema must implement Node, or the schema fails to build.

Implementing Node

Annotate your class with [Node]. Hot Chocolate looks for a static method named Get, GetAsync, Get{TypeName}, or Get{TypeName}Async that accepts the ID as its first parameter and returns the type.

C#
// Types/Product.cs
[Node]
public class Product
{
public int Id { get; set; }
public string Name { get; set; }
public static async Task<Product?> GetAsync(
int id,
CatalogContext db,
CancellationToken ct)
=> await db.Products.FindAsync([id], ct);
}

The [Node] attribute causes the type to implement the Node interface and turns the Id property into a global identifier.

If your ID property is not named Id, specify it:

C#
[Node(IdField = nameof(ProductId))]
public class Product
{
public int ProductId { get; set; }
// ...
}

If your resolver method does not follow the naming convention, annotate it with [NodeResolver]:

C#
[NodeResolver]
public static async Task<Product?> FetchByIdAsync(int id, CatalogContext db, CancellationToken ct)
=> await db.Products.FindAsync([id], ct);

To place the node resolver in a separate class:

C#
[Node(
NodeResolverType = typeof(ProductNodeResolver),
NodeResolver = nameof(ProductNodeResolver.GetProductAsync))]
public class Product
{
public int Id { get; set; }
}
public class ProductNodeResolver
{
public static async Task<Product?> GetProductAsync(
int id, CatalogContext db, CancellationToken ct)
=> await db.Products.FindAsync([id], ct);
}

Node resolvers are ideal places to use DataLoaders for efficient batched fetching.

Node with Type Extensions

When adding Node support through a type extension, place the [Node] attribute on the extension class:

C#
// Types/ProductExtensions.cs
[Node]
[ExtendObjectType<Product>]
public static partial class ProductExtensions
{
public static async Task<Product?> GetAsync(
int id, CatalogContext db, CancellationToken ct)
=> await db.Products.FindAsync([id], ct);
}

Complex IDs

Some data models use composite keys (multiple fields forming a unique identifier). Hot Chocolate supports complex IDs through custom ID types and type converters.

C#
// Types/ProductId.cs
public readonly record struct ProductId(string Sku, int BatchNumber)
{
public override string ToString() => $"{Sku}:{BatchNumber}";
public static ProductId Parse(string value)
{
var parts = value.Split(':');
return new ProductId(parts[0], int.Parse(parts[1]));
}
}
// Types/Product.cs
public class Product
{
[ID]
public ProductId Id { get; set; }
}

Register type converters so Hot Chocolate can serialize and deserialize the complex ID:

C#
// Program.cs
builder
.AddGraphQL()
.AddTypeConverter<string, ProductId>(ProductId.Parse)
.AddTypeConverter<ProductId, string>(x => x.ToString())
.AddGlobalObjectIdentification();

In v16, the source generator can produce a NodeIdValueSerializer for your custom ID type, reducing the need for manual converter registration.

Query Field in Mutation Payloads

Mutation payloads can include a query field that gives clients access to the full Query type. This lets a client fetch everything it needs to update its state in a single round trip.

C#
// Program.cs
builder
.AddGraphQL()
.AddQueryFieldToMutationPayloads();

By default, a query: Query field is added to every mutation payload type whose name ends in Payload. You can customize this:

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

Next Steps

Last updated on April 13, 2026 by Michael Staib