website/src/docs/hotchocolate/v16/building-a-schema/relay.md
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.
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.
// 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:
// 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.
// Types/ProductType.cs
public class ProductType : ObjectType<Product>
{
protected override void Configure(IObjectTypeDescriptor<Product> descriptor)
{
descriptor.Field(f => f.Id).ID();
}
}
For foreign key fields:
descriptor.Field(f => f.ProductId).ID("Product");
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.
// 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:
public static Product? GetProduct(
[ID<Product>] int id,
CatalogContext db)
=> db.Products.Find(id);
This rejects IDs that were serialized for a different type.
</Implementation> <Code>descriptor
.Field("product")
.Argument("id", a => a.Type<NonNullType<IdType>>().ID())
.Type<ProductType>()
.Resolve(context =>
{
var id = context.ArgumentValue<int>("id");
// ...
});
To restrict to a specific type:
.Argument("id", a => a.Type<NonNullType<IdType>>().ID(nameof(Product)))
Mark input object properties with [ID] to deserialize global IDs in input types.
// Types/UpdateProductInput.cs
public class UpdateProductInput
{
[ID]
public int ProductId { get; set; }
public string Name { get; set; }
}
You can access the IIdSerializer service directly to serialize or deserialize global IDs in custom code.
// 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 extends global identifiers by enabling clients to refetch any object by its ID through a standardized node query field. This requires three things:
Node interface.id: ID! field.// Program.cs
builder
.AddGraphQL()
.AddGlobalObjectIdentification();
This adds the Node interface and the node / nodes query fields:
interface Node {
id: ID!
}
type Query {
node(id: ID!): Node
nodes(ids: [ID!]!): [Node]!
}
You can configure options when enabling global object identification:
builder
.AddGraphQL()
.AddGlobalObjectIdentification(opts =>
{
opts.MaxAllowedNodeBatchSize = 50;
});
At least one type in the schema must implement Node, or the schema fails to build.
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.
// 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:
[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]:
[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:
[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);
}
// Types/ProductType.cs
public class ProductType : ObjectType<Product>
{
protected override void Configure(IObjectTypeDescriptor<Product> descriptor)
{
descriptor
.ImplementsNode()
.IdField(f => f.Id)
.ResolveNode(async (context, id) =>
{
var db = context.Service<CatalogContext>();
return await db.Products.FindAsync([id]);
});
}
}
If the ID property is not named Id, specify it with IdField. Hot Chocolate renames it to id in the schema to satisfy the Node interface contract.
To resolve using a separate class:
descriptor
.ImplementsNode()
.IdField(f => f.ProductId)
.ResolveNodeWith<ProductNodeResolver>(r => r.GetProductAsync(default!));
Node resolvers are ideal places to use DataLoaders for efficient batched fetching.
When adding Node support through a type extension, place the [Node] attribute on the extension class:
// 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);
}
Some data models use composite keys (multiple fields forming a unique identifier). Hot Chocolate supports complex IDs through custom ID types and type converters.
// 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:
// 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.
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.
// 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:
builder
.AddGraphQL()
.AddQueryFieldToMutationPayloads(options =>
{
options.QueryFieldName = "rootQuery";
options.MutationPayloadPredicate =
(type) => type.Name.Value.EndsWith("Result");
});