From d48bc420a0d60bcffe622a8345f8d4334148a9b2 Mon Sep 17 00:00:00 2001 From: Jordan Phillips Date: Mon, 13 Jul 2026 10:15:51 +1000 Subject: [PATCH 1/4] docs: fix stale API references in relay.md --- .../docs/hotchocolate/defining-a-schema/relay.md | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/website/content/docs/hotchocolate/defining-a-schema/relay.md b/website/content/docs/hotchocolate/defining-a-schema/relay.md index 95c65360a82..b563f9874a9 100644 --- a/website/content/docs/hotchocolate/defining-a-schema/relay.md +++ b/website/content/docs/hotchocolate/defining-a-schema/relay.md @@ -137,20 +137,20 @@ public class UpdateProductInput ## ID Serializer -You can access the `IIdSerializer` service directly to serialize or deserialize global IDs in custom code. +You can access the `INodeIdSerializer` service directly to serialize or deserialize global IDs in custom code. ```csharp [QueryType] public static partial class ProductQueries { - public static string GetGlobalId(int productId, IIdSerializer serializer) + public static string GetGlobalId(int productId, INodeIdSerializer serializer) { - return serializer.Serialize(null, "Product", productId); + return serializer.Format("Product", productId); } } ``` -The `Serialize` method takes the schema name (or `null` for the default schema), the type name, and the raw ID. +The `Format` method takes the type name and the raw ID. # Global Object Identification @@ -340,7 +340,7 @@ builder .AddGlobalObjectIdentification(); ``` -The source generator can produce a `NodeIdValueSerializer` for your custom ID type, reducing the need for manual converter registration. +Alternatively, call `AddNodeIdValueSerializerFrom()` to have the source generator produce a `NodeIdValueSerializer` for your custom ID type, reducing the need for manual converter registration. # Query Field in Mutation Payloads @@ -352,7 +352,7 @@ builder .AddQueryFieldToMutationPayloads(); ``` -By default, a `query: Query` field is added to every mutation payload type whose name ends in `Payload`. You can customize this: +By default, a `query: Query!` field is added to every mutation payload type whose name ends in `Payload`. You can customize this: ```csharp builder @@ -361,7 +361,7 @@ builder { options.QueryFieldName = "rootQuery"; options.MutationPayloadPredicate = - (type) => type.Name.Value.EndsWith("Result"); + (type) => type.Name.EndsWith("Result"); }); ``` From fdf051769474b9b6302d3dccc26d1f6857e63828 Mon Sep 17 00:00:00 2001 From: Michael Staib Date: Mon, 13 Jul 2026 10:39:40 +0200 Subject: [PATCH 2/4] Update Relay documentation --- .../hotchocolate/defining-a-schema/relay.md | 507 ++++++++++++++---- 1 file changed, 390 insertions(+), 117 deletions(-) diff --git a/website/content/docs/hotchocolate/defining-a-schema/relay.md b/website/content/docs/hotchocolate/defining-a-schema/relay.md index b563f9874a9..00956e3ee89 100644 --- a/website/content/docs/hotchocolate/defining-a-schema/relay.md +++ b/website/content/docs/hotchocolate/defining-a-schema/relay.md @@ -1,20 +1,51 @@ --- title: "Relay" -description: "Implement the Relay server specification in Hot Chocolate: global object identification with [Node] and [ID], node refetching, and mutation payload query fields." +description: "Add global IDs, Node refetching, and query fields on mutation payloads to a Hot Chocolate schema." --- -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. +The [GraphQL Global Object Identification Specification](https://relay.dev/graphql/objectidentification.htm) defines patterns for stable object identification and refetching. These patterns work with every GraphQL client, not only Relay. + +This page covers three related features: + +- Global IDs expose type-aware, opaque IDs to clients while your business code continues to use its original CLR ID values. +- Global object identification adds the `Node` interface and the `node` and `nodes` query fields. +- Mutation payload query fields let a mutation response read from the Query root. + +You enable global ID formatting through `AddGlobalObjectIdentification`. Pass `false` when you need global IDs without the `Node` interface and its query fields. You enable mutation payload query fields separately. For Relay-style connections and cursors, see [Pagination](../fetching-data/pagination.md). + +# Before You Begin + +Install `HotChocolate.AspNetCore` and register a GraphQL server. The examples use these namespaces: + +```csharp +using HotChocolate; +using HotChocolate.Types; +using HotChocolate.Types.Relay; +using Microsoft.Extensions.DependencyInjection; +``` + +The data-access examples also assume that your application registers an Entity Framework Core `CatalogContext` with a `Products` set. + +Enable global ID encoding and decoding before you apply `[ID]` or `.ID()`. If you do not need Node refetching, disable registration of the `Node` interface: + +```csharp +builder + .AddGraphQL() + .AddGlobalObjectIdentification(registerNodeInterface: false); +``` + +This registration adds the default node ID serializer and enables the formatters that encode and decode global IDs. The later Node section shows how to enable object refetching instead. > [!NOTE] -> The patterns on this page benefit all GraphQL clients, not only Relay. We recommend them for every Hot Chocolate project. +> The GraphQL [`ID` scalar](./scalars.md#id) does not make a value globally unique. Hot Chocolate global IDs add the GraphQL type name to a stable ID that is already unique within its type. Clients must treat the resulting value as opaque. -# Global Identifiers +# Expose Global IDs -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 formats a global ID from the GraphQL type name and the raw CLR ID. With the default serializer registration, the external representation uses standard Base64. Other serializer configurations can use URL-safe Base64, hexadecimal, or Base36, so clients must not decode or construct IDs themselves. -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. +## Format Output Fields -## Output Fields +Apply `[ID]` to an output property: @@ -25,13 +56,13 @@ public class Product [ID] public int Id { get; set; } - public string Name { get; set; } + public required 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 this non-nullable `int`, the schema field becomes `id: ID!`. The ID formatter preserves the original nullability and list wrappers. For example, a nullable `int?` becomes `ID`, not `ID!`. -For foreign key fields that reference another type, specify the target type name: +By default, the owning GraphQL type supplies the type name. For a foreign key, specify the referenced type: ```csharp public class OrderItem @@ -44,7 +75,7 @@ public class OrderItem } ``` -The generic `[ID]` form infers the GraphQL type name from the type argument. You can also use `[ID("Product")]` to specify it as a string. +`[ID]` resolves the configured GraphQL name for `Product`. You can pin a literal GraphQL type name with `[ID("Product")]`. @@ -54,23 +85,25 @@ public class ProductType : ObjectType { protected override void Configure(IObjectTypeDescriptor descriptor) { - descriptor.Field(f => f.Id).ID(); + descriptor.Field(product => product.Id).ID(); } } ``` -For foreign key fields: +For a foreign key on an `OrderItemType`, specify the referenced type name: ```csharp -descriptor.Field(f => f.ProductId).ID("Product"); +descriptor.Field(item => item.ProductId).ID("Product"); ``` -## Input Arguments +If `Product.Id` is `1`, the default serializer currently returns `UHJvZHVjdDox`. This value is an implementation detail. Store and return it without interpreting it. + +## Decode IDs in 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. +Mark every argument that accepts a global ID with `[ID]`. Hot Chocolate decodes the external value before it calls your resolver, so `id` remains an `int` in your business code. @@ -80,22 +113,16 @@ When a field returns a serialized global ID, any argument that accepts that ID m public static partial class ProductQueries { public static Product? GetProduct( - [ID] int id, + [ID] int id, CatalogContext db) => db.Products.Find(id); -} -``` - -To restrict the argument to IDs serialized for a specific type: -```csharp -public static Product? GetProduct( - [ID] int id, - CatalogContext db) - => db.Products.Find(id); + public static Product? GetFeaturedProduct(CatalogContext db) + => db.Products.Find(1); +} ``` -This rejects IDs that were serialized for a different type. +Use `[ID]` when the resolver accepts any global ID type. Use `[ID]` or `[ID("Product")]` when it must reject IDs created for another GraphQL type. @@ -103,64 +130,65 @@ This rejects IDs that were serialized for a different type. ```csharp descriptor .Field("product") - .Argument("id", a => a.Type>().ID()) + .Argument("id", argument => + argument.Type>().ID(nameof(Product))) .Type() .Resolve(context => { var id = context.ArgumentValue("id"); - // ... + // Load and return the product. }); ``` -To restrict to a specific type: - -```csharp -.Argument("id", a => a.Type>().ID(nameof(Product))) -``` +Omit the type-name argument from `.ID()` when the resolver accepts any global ID type. -## Input Object Fields +For more argument patterns, see [Arguments](./arguments.md). + +## Decode IDs in Input Objects -Mark input object properties with `[ID]` to deserialize global IDs in input types. +Apply `[ID]` to an input object property to decode the external value during input coercion: ```csharp public class UpdateProductInput { - [ID] + [ID] public int ProductId { get; set; } - public string Name { get; set; } + public required string Name { get; set; } } ``` -## ID Serializer +For a record primary-constructor parameter, target the generated property: + +```csharp +public sealed record UpdateProductInput( + [property: ID] int ProductId, + string Name); +``` + +## Format an ID in Custom Code -You can access the `INodeIdSerializer` service directly to serialize or deserialize global IDs in custom code. +Inject `INodeIdSerializer` when application code needs to format an ID outside an ID field. The GraphQL type and its ID runtime type must be part of the built schema so the schema-scoped serializer can bind them. ```csharp [QueryType] -public static partial class ProductQueries +public static partial class ProductIdQueries { - public static string GetGlobalId(int productId, INodeIdSerializer serializer) - { - return serializer.Format("Product", productId); - } + public static string GetGlobalProductId( + int productId, + INodeIdSerializer serializer) + => serializer.Format("Product", productId); } ``` -The `Format` method takes the type name and the raw ID. +`Format` accepts the GraphQL type name and raw ID. Parsing has a different contract: `Parse` also requires the target runtime type or an `INodeIdRuntimeTypeLookup`, and it returns a `NodeId` containing `TypeName` and `InternalId`. -# Global Object Identification +# Refetch Objects Through `node` -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 +To add the `Node` interface and the `node` and `nodes` query fields, use the default overload instead of the ID-only registration: ```csharp builder @@ -168,7 +196,7 @@ builder .AddGlobalObjectIdentification(); ``` -This adds the `Node` interface and the `node` / `nodes` query fields: +This registration adds the following schema types and fields: ```graphql interface Node { @@ -181,32 +209,26 @@ type Query { } ``` -You can configure options when enabling global object identification: +The `node` result is nullable because an ID might not resolve to an object. The `nodes` field and its list are non-null, while individual list items can be null. -```csharp -builder - .AddGraphQL() - .AddGlobalObjectIdentification(opts => - { - opts.MaxAllowedNodeBatchSize = 50; - }); -``` +To make an object refetchable through `node`, configure all three parts of the contract: -At least one type in the schema must implement `Node`, or the schema fails to build. +1. The object implements `Node`. +2. It exposes `id: ID!`. +3. It has a node resolver that loads one object from its raw ID. -## Implementing Node +At least one object type must implement `Node`, or strict schema validation fails. By default, schema validation also rejects each object that implements `Node` without a node resolver. Set `EnsureAllNodesCanBeResolved` to `false` only when unresolved Node types are intentional. - - +## Implement a Node with Attributes -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. +Apply `[Node]` to the object. Hot Chocolate looks for a public static resolver named `Get`, `GetAsync`, `Get{TypeName}`, or `Get{TypeName}Async`. ```csharp [Node] public class Product { public int Id { get; set; } - public string Name { get; set; } + public required string Name { get; set; } public static async Task GetAsync( int id, @@ -216,28 +238,40 @@ public class Product } ``` -The `[Node]` attribute causes the type to implement the `Node` interface and turns the `Id` property into a global identifier. +`[Node]` makes `Product` implement `Node`, exposes its `Id` property as a global `id: ID!` field, and uses `GetAsync` for node lookup. -If your ID property is not named `Id`, specify it: +If the CLR property has another name, select it explicitly: ```csharp [Node(IdField = nameof(ProductId))] public class Product { public int ProductId { get; set; } - // ... + public required string Name { get; set; } + + public static Product? Get(int id, CatalogContext db) + => db.Products.Find(id); } ``` -If your resolver method does not follow the naming convention, annotate it with `[NodeResolver]`: +The selected CLR member is exposed as `id` to satisfy the `Node` interface. + +To select a resolver that does not follow the naming convention, apply `[NodeResolver]`: ```csharp [NodeResolver] -public static async Task FetchByIdAsync(int id, CatalogContext db, CancellationToken ct) +public static async Task FetchByIdAsync( + int id, + CatalogContext db, + CancellationToken ct) => await db.Products.FindAsync([id], ct); ``` -To place the node resolver in a separate class: +A node resolver must be public. Its first field argument must be named `id` or end in `Id`, and a node resolver can have only one field argument. Services, resolver context, and `CancellationToken` parameters do not count as field arguments. Do not add `[ID]` to the ID parameter because the node resolver already configures it as an ID. + +## Put the Node Resolver in Another Class + +Point `[Node]` at a separate resolver type when you do not want data access on the model: ```csharp [Node( @@ -246,18 +280,22 @@ To place the node resolver in a separate class: public class Product { public int Id { get; set; } + public required string Name { get; set; } } -public class ProductNodeResolver +public sealed class ProductNodeResolver { - public static async Task GetProductAsync( - int id, CatalogContext db, CancellationToken ct) + public async Task GetProductAsync( + int id, + CatalogContext db, + CancellationToken ct) => await db.Products.FindAsync([id], ct); } ``` - - +## Implement a Node with Descriptors + +Use `.ImplementsNode()` when you configure object types with descriptors: ```csharp public class ProductType : ObjectType @@ -266,7 +304,7 @@ public class ProductType : ObjectType { descriptor .ImplementsNode() - .IdField(f => f.Id) + .IdField(product => product.Id) .ResolveNode(async (context, id) => { var db = context.Service(); @@ -276,25 +314,21 @@ public class ProductType : ObjectType } ``` -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: +To use the separate resolver class shown above, identify its full method signature in the expression: ```csharp descriptor .ImplementsNode() - .IdField(f => f.ProductId) - .ResolveNodeWith(r => r.GetProductAsync(default!)); + .IdField(product => product.Id) + .ResolveNodeWith(resolver => + resolver.GetProductAsync(default, default!, default)); ``` - - - -Node resolvers are ideal places to use [DataLoaders](../fetching-data/batching/dataloader.md) for efficient batched fetching. +Node resolvers are a good place to use [DataLoaders](../fetching-data/batching/dataloader.md) when repeated lookups should be batched or cached. -## Node with Type Extensions +## Add Node Support Through a Type Extension -When adding Node support through a type extension, place the `[Node]` attribute on the extension class: +When you add Node support through a type extension, place `[Node]` on the extension class: ```csharp [Node] @@ -302,49 +336,184 @@ When adding Node support through a type extension, place the `[Node]` attribute public static partial class ProductExtensions { public static async Task GetAsync( - int id, CatalogContext db, CancellationToken ct) + int id, + CatalogContext db, + CancellationToken ct) => await db.Products.FindAsync([id], ct); } ``` -# Complex IDs +This static partial extension requires `HotChocolate.Types.Analyzers`. Register it through the generated type module. Projects created from the Hot Chocolate server template do this by calling `.AddTypes()` during server registration. -Some data models use composite keys (multiple fields forming a unique identifier). Hot Chocolate supports complex IDs through custom ID types and type converters. +## Refetch a Node End to End -```csharp -public readonly record struct ProductId(string Sku, int BatchNumber) +Assume the database contains a product with raw ID `1` and name `Green Tea`. First, query a regular field and save the returned global ID: + +```graphql +query GetFeaturedProduct { + featuredProduct { + id + name + } +} +``` + +With the default serializer settings, the response is: + +```json { - public override string ToString() => $"{Sku}:{BatchNumber}"; + "data": { + "featuredProduct": { + "id": "UHJvZHVjdDox", + "name": "Green Tea" + } + } +} +``` - public static ProductId Parse(string value) - { - var parts = value.Split(':'); - return new ProductId(parts[0], int.Parse(parts[1])); +Pass that opaque ID to `node`: + +```graphql +query RefetchProduct($id: ID!) { + node(id: $id) { + id + ... on Product { + name + } + } +} +``` + +**Variables** + +```json +{ + "id": "UHJvZHVjdDox" +} +``` + +The response resolves the same product: + +**Response** + +```json +{ + "data": { + "node": { + "id": "UHJvZHVjdDox", + "name": "Green Tea" } + } } +``` + +The client uses the global ID, while `Product.GetAsync` receives the raw `int` value `1`. + +## Limit Batch Node Lookups + +The `nodes` field accepts a list of IDs. Limit the number of IDs that a single request can resolve: + +```csharp +builder + .AddGraphQL() + .AddGlobalObjectIdentification(options => + { + options.MaxAllowedNodeBatchSize = 50; + }); +``` + +The default limit is `50`. Requests above the configured limit fail with error code `HC0076`. See [Global object identification options](../server/options.md#global-object-identification-options) for all options and [Nodes batch size](../security/request-limits.md#nodes-batch-size) for security guidance. + +# Use Composite CLR IDs + +The built-in node ID serializers support common scalar CLR types. For a composite ID, use the `HotChocolate.Types.Analyzers` source generator to create and register an `INodeIdValueSerializer` implementation. + +Define the ID as a record struct whose components are public readable properties: + +```csharp +public readonly record struct ProductId(string Sku, int BatchNumber); public class Product { [ID] public ProductId Id { get; set; } + + public required string Name { get; set; } } ``` -Register type converters so Hot Chocolate can serialize and deserialize the complex ID: +The Hot Chocolate server template already references `HotChocolate.Types.Analyzers`. If your project does not, add the package with the same version as your other Hot Chocolate packages. Replace `` with that version: + +```bash +dotnet add package HotChocolate.Types.Analyzers --version +``` + +Then call the generator activation method in your server registration: ```csharp builder .AddGraphQL() - .AddTypeConverter(ProductId.Parse) - .AddTypeConverter(x => x.ToString()) - .AddGlobalObjectIdentification(); + .AddNodeIdValueSerializerFrom() + .AddGlobalObjectIdentification(registerNodeInterface: false); +``` + +The call generates and registers a serializer for `ProductId`. The generated serializer supports components of type `string`, `short`, `int`, `long`, `bool`, and `Guid`. Use public readable instance properties. A positional record or a type with a matching constructor can use read-only properties. Other supported types need assignable properties. + +Every property that participates in identity must use one of the supported types. The generator ignores unsupported properties. Implement a custom serializer when any identity component uses another type. General Hot Chocolate type converters do not register a node ID value serializer. + +If the generated shape does not fit your ID type, implement `INodeIdValueSerializer` or derive from `CompositeNodeIdValueSerializer`, then register it with `.AddNodeIdValueSerializer()`. + +The following resolver exposes the decoded components so you can verify the round trip: + +```csharp +[QueryType] +public static partial class CompositeProductQueries +{ + public static Product GetCompositeProduct( + [ID] ProductId id) + => new() + { + Id = id, + Name = $"{id.Sku}/{id.BatchNumber}" + }; +} ``` -Alternatively, call `AddNodeIdValueSerializerFrom()` to have the source generator produce a `NodeIdValueSerializer` for your custom ID type, reducing the need for manual converter registration. +With the default serializer, `ProductId("ABC", 42)` is represented by `UHJvZHVjdDpBQkM6NDI=`. Pass that value back without decoding or constructing it in client code: -# Query Field in Mutation Payloads +```graphql +query GetCompositeProduct($id: ID!) { + compositeProduct(id: $id) { + id + name + } +} +``` -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. +**Variables** + +```json +{ + "id": "UHJvZHVjdDpBQkM6NDI=" +} +``` + +**Response** + +```json +{ + "data": { + "compositeProduct": { + "id": "UHJvZHVjdDpBQkM6NDI=", + "name": "ABC/42" + } + } +} +``` + +# Query from a Mutation Payload + +Mutation payload query fields are independent of global IDs and the `node` field. Enable them separately: ```csharp builder @@ -352,7 +521,88 @@ builder .AddQueryFieldToMutationPayloads(); ``` -By default, a `query: Query!` field is added to every mutation payload type whose name ends in `Payload`. You can customize this: +By default, Hot Chocolate adds `query: Query!` to object types returned by mutation fields whose names end in `Payload`. The field points to your configured Query root, even when that root has another GraphQL name. + +For example, define a mutation payload: + +```csharp +[MutationType] +public static partial class ProductMutations +{ + public static async Task RenameProductAsync( + [ID] int id, + string name, + CatalogContext db, + CancellationToken ct) + { + var product = await db.Products.FindAsync([id], ct) + ?? throw new GraphQLException("Product not found."); + + product.Name = name; + await db.SaveChangesAsync(ct); + return new RenameProductPayload(product); + } +} + +public sealed record RenameProductPayload(Product Product); +``` + +The payload includes the added field: + +```graphql +type RenameProductPayload { + product: Product! + query: Query! +} +``` + +You can return the changed object and refetch other query data in the same request: + +```graphql +mutation RenameProduct($id: ID!) { + renameProduct(id: $id, name: "Green Tea") { + product { + id + name + } + query { + product(id: $id) { + name + } + } + } +} +``` + +**Variables** + +```json +{ + "id": "UHJvZHVjdDox" +} +``` + +**Response** + +```json +{ + "data": { + "renameProduct": { + "product": { + "id": "UHJvZHVjdDox", + "name": "Green Tea" + }, + "query": { + "product": { + "name": "Green Tea" + } + } + } + } +} +``` + +Customize the field name and the payload-type predicate when your naming convention differs: ```csharp builder @@ -360,14 +610,37 @@ builder .AddQueryFieldToMutationPayloads(options => { options.QueryFieldName = "rootQuery"; - options.MutationPayloadPredicate = - (type) => type.Name.EndsWith("Result"); + options.MutationPayloadPredicate = type => + type.Name.EndsWith("Result", StringComparison.Ordinal); }); ``` +This configuration adds `rootQuery: Query!` only to matching mutation result types. + +# Troubleshooting + +## `HotChocolate.Types.Analyzers is required to use this method.` + +Your application called `AddNodeIdValueSerializerFrom()` without the analyzer that intercepts the call. Reference `HotChocolate.Types.Analyzers` with the same version as the server packages, then rebuild the project. + +## No serializer registered for type `ProductId`. + +The default serializers do not know how to format your custom CLR ID. Generate one with `AddNodeIdValueSerializerFrom()`, or register a custom implementation with `AddNodeIdValueSerializer()`. General type converters do not participate in node ID formatting. + +## The Schema Rejects a Type That Implements `Node` + +Each Node object needs a node resolver when `EnsureAllNodesCanBeResolved` is enabled, which is the default. Add a resolver through `[Node]`, `[NodeResolver]`, `.ResolveNode(...)`, or `.ResolveNodeWith(...)`. + +If the type intentionally cannot be refetched, reconsider whether it should implement `Node`. You can opt out of validation with `EnsureAllNodesCanBeResolved = false`, but `node` cannot resolve that type. See [Global object identification options](../server/options.md#global-object-identification-options). + +## A Node Resolver Produces an Analyzer Error + +Make the resolver public and name its first field argument `id` or use a name that ends in `Id`. Do not annotate that argument with `[ID]`. If an ID appears on a record primary-constructor parameter, target the property with `[property: ID]`. + # Next Steps -- **Need to fetch data efficiently?** See [DataLoader](../fetching-data/batching/dataloader.md). -- **Need pagination?** See [Pagination](../fetching-data/pagination.md). -- **Need to understand ID types?** See [Scalars](./scalars.md). -- **Need to extend types?** See [Extending Types](./object-types.md). +- Use [DataLoader](../fetching-data/batching/dataloader.md) to batch and cache node lookups. +- Add Relay-style connections with [Pagination](../fetching-data/pagination.md). +- Review the [`ID` scalar](./scalars.md#id) and other [Scalars](./scalars.md). +- Configure schema objects with [Object Types](./object-types.md). +- For distributed schemas, see [GraphQL global object identification in Fusion](../../fusion/entities-and-lookups.md#graphql-global-object-identification). From c7fdd48120ff71b39a108b1227294f41a490f7ec Mon Sep 17 00:00:00 2001 From: Michael Staib Date: Mon, 13 Jul 2026 10:44:51 +0200 Subject: [PATCH 3/4] Simplify Relay documentation wording --- website/content/docs/hotchocolate/defining-a-schema/relay.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/website/content/docs/hotchocolate/defining-a-schema/relay.md b/website/content/docs/hotchocolate/defining-a-schema/relay.md index 00956e3ee89..a9a3fc246b7 100644 --- a/website/content/docs/hotchocolate/defining-a-schema/relay.md +++ b/website/content/docs/hotchocolate/defining-a-schema/relay.md @@ -7,7 +7,7 @@ The [GraphQL Global Object Identification Specification](https://relay.dev/graph This page covers three related features: -- Global IDs expose type-aware, opaque IDs to clients while your business code continues to use its original CLR ID values. +- Global IDs expose type-aware, opaque IDs to clients while your application continues to use the original ID values. - Global object identification adds the `Node` interface and the `node` and `nodes` query fields. - Mutation payload query fields let a mutation response read from the Query root. From 24df99d84f21c534dc6112de08f51a46f3610722 Mon Sep 17 00:00:00 2001 From: Michael Staib Date: Mon, 13 Jul 2026 11:30:56 +0200 Subject: [PATCH 4/4] Rewrite Relay guide for beginners --- .../hotchocolate/defining-a-schema/relay.md | 584 ++++++------------ 1 file changed, 175 insertions(+), 409 deletions(-) diff --git a/website/content/docs/hotchocolate/defining-a-schema/relay.md b/website/content/docs/hotchocolate/defining-a-schema/relay.md index a9a3fc246b7..33f1f280afc 100644 --- a/website/content/docs/hotchocolate/defining-a-schema/relay.md +++ b/website/content/docs/hotchocolate/defining-a-schema/relay.md @@ -1,21 +1,27 @@ --- title: "Relay" -description: "Add global IDs, Node refetching, and query fields on mutation payloads to a Hot Chocolate schema." +description: "Give objects global IDs and let clients refetch them through the Relay node field." --- -The [GraphQL Global Object Identification Specification](https://relay.dev/graphql/objectidentification.htm) defines patterns for stable object identification and refetching. These patterns work with every GraphQL client, not only Relay. +A `Product` and an `Order` can both have the ID `1`. The value `1` alone does not tell a client which object it belongs to. This can cause collisions in a client cache. -This page covers three related features: +Hot Chocolate solves this problem with a global ID. The server combines the object type and its ID into one opaque string. Opaque means that the client stores and returns the string without trying to read it. Your application still works with the original ID. -- Global IDs expose type-aware, opaque IDs to clients while your application continues to use the original ID values. -- Global object identification adds the `Node` interface and the `node` and `nodes` query fields. -- Mutation payload query fields let a mutation response read from the Query root. +By the end of this guide, you will return a global ID for a product and use that ID to fetch the same product through the `node` query field. -You enable global ID formatting through `AddGlobalObjectIdentification`. Pass `false` when you need global IDs without the `Node` interface and its query fields. You enable mutation payload query fields separately. For Relay-style connections and cursors, see [Pagination](../fetching-data/pagination.md). +These patterns follow the [GraphQL Global Object Identification Specification](https://relay.dev/graphql/objectidentification.htm). They work with every GraphQL client, not only Relay. For Relay-style connections and cursors, see [Pagination](../fetching-data/pagination.md). # Before You Begin -Install `HotChocolate.AspNetCore` and register a GraphQL server. The examples use these namespaces: +Start with a running Hot Chocolate server created from the server template. If you do not have one, complete [Getting Started](../get-started-with-graphql-in-net-core.md) first. + +The examples use an Entity Framework Core `CatalogContext` with a `Products` set. See [Entity Framework](../fetching-data/integrations/entity-framework.md) for database registration. The examples assume the database contains this product: + +| ID | Name | +| --- | --------- | +| 1 | Green Tea | + +The examples use these namespaces: ```csharp using HotChocolate; @@ -24,330 +30,80 @@ using HotChocolate.Types.Relay; using Microsoft.Extensions.DependencyInjection; ``` -The data-access examples also assume that your application registers an Entity Framework Core `CatalogContext` with a `Products` set. +# Add Global Object Identification -Enable global ID encoding and decoding before you apply `[ID]` or `.ID()`. If you do not need Node refetching, disable registration of the `Node` interface: +Add global object identification to the existing server registration. Keep the template's generated `.AddTypes()` call: ```csharp builder .AddGraphQL() - .AddGlobalObjectIdentification(registerNodeInterface: false); + .AddGlobalObjectIdentification() + .AddTypes(); ``` -This registration adds the default node ID serializer and enables the formatters that encode and decode global IDs. The later Node section shows how to enable object refetching instead. - -> [!NOTE] -> The GraphQL [`ID` scalar](./scalars.md#id) does not make a value globally unique. Hot Chocolate global IDs add the GraphQL type name to a stable ID that is already unique within its type. Clients must treat the resulting value as opaque. - -# Expose Global IDs +This enables global ID encoding and decoding. It also adds the `Node` interface and the `node` and `nodes` fields to Query. -Hot Chocolate formats a global ID from the GraphQL type name and the raw CLR ID. With the default serializer registration, the external representation uses standard Base64. Other serializer configurations can use URL-safe Base64, hexadecimal, or Base36, so clients must not decode or construct IDs themselves. +# Make Product Refetchable -## Format Output Fields - -Apply `[ID]` to an output property: - - - +Add `[Node]` to `Product` and provide a method that loads one product by ID: ```csharp -public class Product +[Node] +public sealed class Product { - [ID] public int Id { get; set; } public required string Name { get; set; } -} -``` - -For this non-nullable `int`, the schema field becomes `id: ID!`. The ID formatter preserves the original nullability and list wrappers. For example, a nullable `int?` becomes `ID`, not `ID!`. - -By default, the owning GraphQL type supplies the type name. For a foreign key, specify the referenced type: - -```csharp -public class OrderItem -{ - [ID] - public int Id { get; set; } - [ID] - public int ProductId { get; set; } + public static async Task GetAsync( + int id, + CatalogContext db, + CancellationToken cancellationToken) + => await db.Products.FindAsync([id], cancellationToken); } ``` -`[ID]` resolves the configured GraphQL name for `Product`. You can pin a literal GraphQL type name with `[ID("Product")]`. +`[Node]` does three things: - - - -```csharp -public class ProductType : ObjectType -{ - protected override void Configure(IObjectTypeDescriptor descriptor) - { - descriptor.Field(product => product.Id).ID(); - } -} -``` +1. It makes `Product` implement the GraphQL `Node` interface. +2. It exposes `Product.Id` as a global `ID!` field. +3. It uses `GetAsync` when a client fetches a product through `node`. -For a foreign key on an `OrderItemType`, specify the referenced type name: - -```csharp -descriptor.Field(item => item.ProductId).ID("Product"); -``` - - - - -If `Product.Id` is `1`, the default serializer currently returns `UHJvZHVjdDox`. This value is an implementation detail. Store and return it without interpreting it. - -## Decode IDs in Arguments - -Mark every argument that accepts a global ID with `[ID]`. Hot Chocolate decodes the external value before it calls your resolver, so `id` remains an `int` in your business code. - - - +Next, add a regular query field that returns the sample product: ```csharp [QueryType] public static partial class ProductQueries { - public static Product? GetProduct( - [ID] int id, - CatalogContext db) - => db.Products.Find(id); - public static Product? GetFeaturedProduct(CatalogContext db) => db.Products.Find(1); } ``` -Use `[ID]` when the resolver accepts any global ID type. Use `[ID]` or `[ID("Product")]` when it must reject IDs created for another GraphQL type. - - - - -```csharp -descriptor - .Field("product") - .Argument("id", argument => - argument.Type>().ID(nameof(Product))) - .Type() - .Resolve(context => - { - var id = context.ArgumentValue("id"); - // Load and return the product. - }); -``` - -Omit the type-name argument from `.ID()` when the resolver accepts any global ID type. - - - - -For more argument patterns, see [Arguments](./arguments.md). - -## Decode IDs in Input Objects - -Apply `[ID]` to an input object property to decode the external value during input coercion: - -```csharp -public class UpdateProductInput -{ - [ID] - public int ProductId { get; set; } - - public required string Name { get; set; } -} -``` - -For a record primary-constructor parameter, target the generated property: - -```csharp -public sealed record UpdateProductInput( - [property: ID] int ProductId, - string Name); -``` - -## Format an ID in Custom Code - -Inject `INodeIdSerializer` when application code needs to format an ID outside an ID field. The GraphQL type and its ID runtime type must be part of the built schema so the schema-scoped serializer can bind them. - -```csharp -[QueryType] -public static partial class ProductIdQueries -{ - public static string GetGlobalProductId( - int productId, - INodeIdSerializer serializer) - => serializer.Format("Product", productId); -} -``` - -`Format` accepts the GraphQL type name and raw ID. Parsing has a different contract: `Parse` also requires the target runtime type or an `INodeIdRuntimeTypeLookup`, and it returns a `NodeId` containing `TypeName` and `InternalId`. - -# Refetch Objects Through `node` - -To add the `Node` interface and the `node` and `nodes` query fields, use the default overload instead of the ID-only registration: - -```csharp -builder - .AddGraphQL() - .AddGlobalObjectIdentification(); -``` - -This registration adds the following schema types and fields: +If your setup is correct, the schema contains these fields: ```graphql interface Node { id: ID! } +type Product implements Node { + id: ID! + name: String! +} + type Query { + featuredProduct: Product node(id: ID!): Node nodes(ids: [ID!]!): [Node]! } ``` -The `node` result is nullable because an ID might not resolve to an object. The `nodes` field and its list are non-null, while individual list items can be null. - -To make an object refetchable through `node`, configure all three parts of the contract: - -1. The object implements `Node`. -2. It exposes `id: ID!`. -3. It has a node resolver that loads one object from its raw ID. - -At least one object type must implement `Node`, or strict schema validation fails. By default, schema validation also rejects each object that implements `Node` without a node resolver. Set `EnsureAllNodesCanBeResolved` to `false` only when unresolved Node types are intentional. - -## Implement a Node with Attributes - -Apply `[Node]` to the object. Hot Chocolate looks for a public static resolver named `Get`, `GetAsync`, `Get{TypeName}`, or `Get{TypeName}Async`. - -```csharp -[Node] -public class Product -{ - public int Id { get; set; } - public required string Name { get; set; } - - public static async Task GetAsync( - int id, - CatalogContext db, - CancellationToken ct) - => await db.Products.FindAsync([id], ct); -} -``` - -`[Node]` makes `Product` implement `Node`, exposes its `Id` property as a global `id: ID!` field, and uses `GetAsync` for node lookup. - -If the CLR property has another name, select it explicitly: - -```csharp -[Node(IdField = nameof(ProductId))] -public class Product -{ - public int ProductId { get; set; } - public required string Name { get; set; } - - public static Product? Get(int id, CatalogContext db) - => db.Products.Find(id); -} -``` - -The selected CLR member is exposed as `id` to satisfy the `Node` interface. - -To select a resolver that does not follow the naming convention, apply `[NodeResolver]`: - -```csharp -[NodeResolver] -public static async Task FetchByIdAsync( - int id, - CatalogContext db, - CancellationToken ct) - => await db.Products.FindAsync([id], ct); -``` - -A node resolver must be public. Its first field argument must be named `id` or end in `Id`, and a node resolver can have only one field argument. Services, resolver context, and `CancellationToken` parameters do not count as field arguments. Do not add `[ID]` to the ID parameter because the node resolver already configures it as an ID. - -## Put the Node Resolver in Another Class - -Point `[Node]` at a separate resolver type when you do not want data access on the model: - -```csharp -[Node( - NodeResolverType = typeof(ProductNodeResolver), - NodeResolver = nameof(ProductNodeResolver.GetProductAsync))] -public class Product -{ - public int Id { get; set; } - public required string Name { get; set; } -} - -public sealed class ProductNodeResolver -{ - public async Task GetProductAsync( - int id, - CatalogContext db, - CancellationToken ct) - => await db.Products.FindAsync([id], ct); -} -``` - -## Implement a Node with Descriptors - -Use `.ImplementsNode()` when you configure object types with descriptors: - -```csharp -public class ProductType : ObjectType -{ - protected override void Configure(IObjectTypeDescriptor descriptor) - { - descriptor - .ImplementsNode() - .IdField(product => product.Id) - .ResolveNode(async (context, id) => - { - var db = context.Service(); - return await db.Products.FindAsync([id]); - }); - } -} -``` - -To use the separate resolver class shown above, identify its full method signature in the expression: - -```csharp -descriptor - .ImplementsNode() - .IdField(product => product.Id) - .ResolveNodeWith(resolver => - resolver.GetProductAsync(default, default!, default)); -``` - -Node resolvers are a good place to use [DataLoaders](../fetching-data/batching/dataloader.md) when repeated lookups should be batched or cached. - -## Add Node Support Through a Type Extension - -When you add Node support through a type extension, place `[Node]` on the extension class: - -```csharp -[Node] -[ExtendObjectType] -public static partial class ProductExtensions -{ - public static async Task GetAsync( - int id, - CatalogContext db, - CancellationToken ct) - => await db.Products.FindAsync([id], ct); -} -``` - -This static partial extension requires `HotChocolate.Types.Analyzers`. Register it through the generated type module. Projects created from the Hot Chocolate server template do this by calling `.AddTypes()` during server registration. +The `node` field fetches one object. The `nodes` field fetches several objects in one request. -## Refetch a Node End to End +# Get a Global ID -Assume the database contains a product with raw ID `1` and name `Green Tea`. First, query a regular field and save the returned global ID: +Query the regular `featuredProduct` field: ```graphql query GetFeaturedProduct { @@ -358,7 +114,7 @@ query GetFeaturedProduct { } ``` -With the default serializer settings, the response is: +With the default serializer, the response is: ```json { @@ -371,7 +127,13 @@ With the default serializer settings, the response is: } ``` -Pass that opaque ID to `node`: +The product still has the ID `1` in your application. Hot Chocolate can read `UHJvZHVjdDox` and determine that it refers to a `Product`. + +The default serializer uses Base64. Other configurations can produce a different string, so clients must treat every global ID as opaque. + +# Refetch the Product Through `node` + +Pass the returned ID to the `node` field: ```graphql query RefetchProduct($id: ID!) { @@ -392,8 +154,6 @@ query RefetchProduct($id: ID!) { } ``` -The response resolves the same product: - **Response** ```json @@ -407,123 +167,85 @@ The response resolves the same product: } ``` -The client uses the global ID, while `Product.GetAsync` receives the raw `int` value `1`. - -## Limit Batch Node Lookups - -The `nodes` field accepts a list of IDs. Limit the number of IDs that a single request can resolve: - -```csharp -builder - .AddGraphQL() - .AddGlobalObjectIdentification(options => - { - options.MaxAllowedNodeBatchSize = 50; - }); -``` - -The default limit is `50`. Requests above the configured limit fail with error code `HC0076`. See [Global object identification options](../server/options.md#global-object-identification-options) for all options and [Nodes batch size](../security/request-limits.md#nodes-batch-size) for security guidance. - -# Use Composite CLR IDs +Hot Chocolate reads the global ID, finds the `Product` type, and calls `Product.GetAsync` with the original integer `1`. If no product has that ID, `node` returns `null`. -The built-in node ID serializers support common scalar CLR types. For a composite ID, use the `HotChocolate.Types.Analyzers` source generator to create and register an `INodeIdValueSerializer` implementation. +# Accept a Global ID as Input -Define the ID as a record struct whose components are public readable properties: +Add a query field that accepts the global Product ID returned above: ```csharp -public readonly record struct ProductId(string Sku, int BatchNumber); - -public class Product +[QueryType] +public static partial class ProductQueries { - [ID] - public ProductId Id { get; set; } - - public required string Name { get; set; } + public static Product? GetProduct( + [ID] int id, + CatalogContext db) + => db.Products.Find(id); } ``` -The Hot Chocolate server template already references `HotChocolate.Types.Analyzers`. If your project does not, add the package with the same version as your other Hot Chocolate packages. Replace `` with that version: +This declaration can live beside the earlier `ProductQueries` declaration because both are `partial`. -```bash -dotnet add package HotChocolate.Types.Analyzers --version -``` +On an argument or input property, `[ID]` checks that the supplied global ID belongs to `Product`. Hot Chocolate then passes the original integer to your code. A global ID for another type is rejected before the resolver runs. -Then call the generator activation method in your server registration: +The field appears in the schema as: -```csharp -builder - .AddGraphQL() - .AddNodeIdValueSerializerFrom() - .AddGlobalObjectIdentification(registerNodeInterface: false); +```graphql +type Query { + product(id: ID!): Product +} ``` -The call generates and registers a serializer for `ProductId`. The generated serializer supports components of type `string`, `short`, `int`, `long`, `bool`, and `Guid`. Use public readable instance properties. A positional record or a type with a matching constructor can use read-only properties. Other supported types need assignable properties. +# Expose Other Values as Global IDs -Every property that participates in identity must use one of the supported types. The generator ignores unsupported properties. Implement a custom serializer when any identity component uses another type. General Hot Chocolate type converters do not register a node ID value serializer. +The GraphQL [`ID` scalar](./scalars.md#id) is not automatically a global ID. Add `[ID]` only when you want Hot Chocolate to encode a value as a global ID. -If the generated shape does not fit your ID type, implement `INodeIdValueSerializer` or derive from `CompositeNodeIdValueSerializer`, then register it with `.AddNodeIdValueSerializer()`. - -The following resolver exposes the decoded components so you can verify the round trip: +For an object's own ID, use `[ID]`. For a field that points to another object type, supply that type to the attribute: ```csharp -[QueryType] -public static partial class CompositeProductQueries +public sealed class OrderItem { - public static Product GetCompositeProduct( - [ID] ProductId id) - => new() - { - Id = id, - Name = $"{id.Sku}/{id.BatchNumber}" - }; -} -``` - -With the default serializer, `ProductId("ABC", 42)` is represented by `UHJvZHVjdDpBQkM6NDI=`. Pass that value back without decoding or constructing it in client code: + [ID] + public int Id { get; set; } -```graphql -query GetCompositeProduct($id: ID!) { - compositeProduct(id: $id) { - id - name - } + [ID] + public int ProductId { get; set; } } ``` -**Variables** +On this output type, `[ID]` uses `OrderItem` as the type name for `id`. `[ID]` uses `Product` as the type name for `productId`. -```json +You can apply the typed attribute to an input property too: + +```csharp +public sealed class UpdateProductInput { - "id": "UHJvZHVjdDpBQkM6NDI=" + [ID] + public int ProductId { get; set; } + + public required string Name { get; set; } } ``` -**Response** +On input, the attribute checks that the ID belongs to `Product` and then supplies the original integer value. Hot Chocolate also preserves nullability. For example, `[ID] int` becomes `ID!`, while `[ID] int?` becomes `ID`. -```json -{ - "data": { - "compositeProduct": { - "id": "UHJvZHVjdDpBQkM6NDI=", - "name": "ABC/42" - } - } -} -``` +For more argument and input examples, see [Arguments](./arguments.md). + +# Optional: Query Updated Data from a Mutation -# Query from a Mutation Payload +Mutation payload querying is a separate feature. It lets a client change data and read the updated state through top-level Query fields in one request. -Mutation payload query fields are independent of global IDs and the `node` field. Enable them separately: +Add it to the registration from the main tutorial: ```csharp builder .AddGraphQL() - .AddQueryFieldToMutationPayloads(); + .AddGlobalObjectIdentification() + .AddQueryFieldToMutationPayloads() + .AddTypes(); ``` -By default, Hot Chocolate adds `query: Query!` to object types returned by mutation fields whose names end in `Payload`. The field points to your configured Query root, even when that root has another GraphQL name. - -For example, define a mutation payload: +Hot Chocolate adds `query: Query!` to mutation result types whose names end in `Payload`. For example: ```csharp [MutationType] @@ -533,13 +255,13 @@ public static partial class ProductMutations [ID] int id, string name, CatalogContext db, - CancellationToken ct) + CancellationToken cancellationToken) { - var product = await db.Products.FindAsync([id], ct) + var product = await db.Products.FindAsync([id], cancellationToken) ?? throw new GraphQLException("Product not found."); product.Name = name; - await db.SaveChangesAsync(ct); + await db.SaveChangesAsync(cancellationToken); return new RenameProductPayload(product); } } @@ -547,20 +269,11 @@ public static partial class ProductMutations public sealed record RenameProductPayload(Product Product); ``` -The payload includes the added field: - -```graphql -type RenameProductPayload { - product: Product! - query: Query! -} -``` - -You can return the changed object and refetch other query data in the same request: +The client can return the changed product and query it again from the same payload: ```graphql mutation RenameProduct($id: ID!) { - renameProduct(id: $id, name: "Green Tea") { + renameProduct(id: $id, name: "Black Tea") { product { id name @@ -590,11 +303,11 @@ mutation RenameProduct($id: ID!) { "renameProduct": { "product": { "id": "UHJvZHVjdDox", - "name": "Green Tea" + "name": "Black Tea" }, "query": { "product": { - "name": "Green Tea" + "name": "Black Tea" } } } @@ -602,45 +315,98 @@ mutation RenameProduct($id: ID!) { } ``` -Customize the field name and the payload-type predicate when your naming convention differs: +# Advanced: Use a Composite ID + +This section is an alternative to the integer ID used in the main tutorial. Do not apply it if your Product uses a single integer ID. + +Assume that Entity Framework is configured to use both `Sku` and `BatchNumber` as the Product key. Define an ID type with those two parts: + +```csharp +public readonly record struct ProductId(string Sku, int BatchNumber); +``` + +Register a generated serializer for this ID type before global object identification: ```csharp builder .AddGraphQL() - .AddQueryFieldToMutationPayloads(options => - { - options.QueryFieldName = "rootQuery"; - options.MutationPayloadPredicate = type => - type.Name.EndsWith("Result", StringComparison.Ordinal); - }); + .AddNodeIdValueSerializerFrom() + .AddGlobalObjectIdentification() + .AddTypes(); ``` -This configuration adds `rootQuery: Query!` only to matching mutation result types. +Then replace the integer ID and resolvers from the main tutorial with these versions: -# Troubleshooting +```csharp +[Node] +public sealed class Product +{ + public ProductId Id { get; set; } -## `HotChocolate.Types.Analyzers is required to use this method.` + public required string Name { get; set; } -Your application called `AddNodeIdValueSerializerFrom()` without the analyzer that intercepts the call. Reference `HotChocolate.Types.Analyzers` with the same version as the server packages, then rebuild the project. + public static async Task GetAsync( + ProductId id, + CatalogContext db, + CancellationToken cancellationToken) + => await db.Products.FindAsync([id.Sku, id.BatchNumber], cancellationToken); +} -## No serializer registered for type `ProductId`. +[QueryType] +public static partial class ProductQueries +{ + public static Product? GetFeaturedProduct(CatalogContext db) + => db.Products.Find("ABC", 42); -The default serializers do not know how to format your custom CLR ID. Generate one with `AddNodeIdValueSerializerFrom()`, or register a custom implementation with `AddNodeIdValueSerializer()`. General type converters do not participate in node ID formatting. + public static Product? GetProduct( + [ID] ProductId id, + CatalogContext db) + => db.Products.Find(id.Sku, id.BatchNumber); +} +``` -## The Schema Rejects a Type That Implements `Node` +`AddNodeIdValueSerializerFrom()` needs the `HotChocolate.Types.Analyzers` package. The Hot Chocolate server template already includes it. If your project does not, add the package with the same version as your other Hot Chocolate packages: -Each Node object needs a node resolver when `EnsureAllNodesCanBeResolved` is enabled, which is the default. Add a resolver through `[Node]`, `[NodeResolver]`, `.ResolveNode(...)`, or `.ResolveNodeWith(...)`. +```bash +dotnet add package HotChocolate.Types.Analyzers --version +``` -If the type intentionally cannot be refetched, reconsider whether it should implement `Node`. You can opt out of validation with `EnsureAllNodesCanBeResolved = false`, but `node` cannot resolve that type. See [Global object identification options](../server/options.md#global-object-identification-options). +With the default serializer, a Product ID containing `"ABC"` and `42` is returned as: -## A Node Resolver Produces an Analyzer Error +```json +{ + "data": { + "featuredProduct": { + "id": "UHJvZHVjdDpBQkM6NDI=", + "name": "Green Tea" + } + } +} +``` + +Clients must still treat this value as opaque. The generated serializer supports ID parts of type `string`, `short`, `int`, `long`, `bool`, and `Guid`. If your ID uses other part types, implement `INodeIdValueSerializer` and register it with `AddNodeIdValueSerializer()`. + +# Troubleshooting + +## Attributed Fields Are Missing from the Schema + +Keep the generated `.AddTypes()` call from the server template. It registers types marked with attributes such as `[QueryType]` and `[MutationType]`. + +## `There is no object type implementing interface Node.` + +`AddGlobalObjectIdentification()` adds the `Node` interface. Add `[Node]` to at least one object type and give that type a node resolver. + +## A Node Type Has No Resolver + +Every Node type needs a public static method that can load one object by ID. Hot Chocolate recognizes `Get`, `GetAsync`, `Get{TypeName}`, and `Get{TypeName}Async`. Use `[NodeResolver]` if the method has another name. + +## `HotChocolate.Types.Analyzers is required to use this method.` -Make the resolver public and name its first field argument `id` or use a name that ends in `Id`. Do not annotate that argument with `[ID]`. If an ID appears on a record primary-constructor parameter, target the property with `[property: ID]`. +Your project called `AddNodeIdValueSerializerFrom()` without the build-time generator. Add `HotChocolate.Types.Analyzers` with the same version as your other Hot Chocolate packages, then rebuild. # Next Steps -- Use [DataLoader](../fetching-data/batching/dataloader.md) to batch and cache node lookups. +- Use [DataLoader](../fetching-data/batching/dataloader.md) to batch or cache repeated node lookups. +- Review [global object identification options](../server/options.md#global-object-identification-options), including the `nodes` batch limit. - Add Relay-style connections with [Pagination](../fetching-data/pagination.md). -- Review the [`ID` scalar](./scalars.md#id) and other [Scalars](./scalars.md). -- Configure schema objects with [Object Types](./object-types.md). - For distributed schemas, see [GraphQL global object identification in Fusion](../../fusion/entities-and-lookups.md#graphql-global-object-identification).