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
// Types/Product.cspublic 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.cspublic 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.
// 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.
Input Object Fields
Mark input object properties with [ID] to deserialize global IDs in input types.
// Types/UpdateProductInput.cspublic 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.
// 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:
- The type implements the
Nodeinterface. - The type has an
id: ID!field. - A node resolver method can fetch the object by its ID.
Enabling Global Object Identification
// Program.csbuilder .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.
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.
// 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);}
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:
// 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.
// Types/ProductId.cspublic 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.cspublic class Product{ [ID] public ProductId Id { get; set; }}
Register type converters so Hot Chocolate can serialize and deserialize the complex ID:
// Program.csbuilder .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.
// Program.csbuilder .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"); });
Next Steps
- Need to fetch data efficiently? See DataLoader.
- Need pagination? See Pagination.
- Need to understand ID types? See Scalars.
- Need to extend types? See Extending Types.