Migrate Hot Chocolate Fusion from 15 to 16

Note: While directives and behavior largely mirror v15, v16 is a complete re-implementation of Fusion that not only affects the gateway itself, but also the archive format and composition process. Therefore, you can't simply bump the package versions in the gateway and be done with the update. You'll need a coordinated strategy to incrementally adopt Fusion v2 in Subgraphs and their deployment process, before you can switch the gateway to v16.

Migration at a glance

The migration happens in three stages, and the gateway keeps serving traffic the whole time:

  1. Migrate the subgraph repositories. Update each subgraph so its pipeline publishes both archive formats side by side. Subgraphs can be migrated independently and in parallel. This stage does not require migrating the subgraph to Hot Chocolate v16.
  2. Upgrade and cut over the gateway. Once every subgraph publishes both archives, upgrade the gateway to the v16 packages and switch it to consume the .far. Until the subgraphs stop publishing the .fgp format, this is reversible.
  3. Clean up. Remove the v1 compose and publishing step from each subgraph so it only publishes the .far, and optionally migrate the subgraphs to Hot Chocolate v16.

Per subgraph migration

Migrate subgraph-config.json

For each subgraph in your repository, the existing subgraph-config.json file needs to be migrated to the new schema-settings.json format.

You can run the following command in the root of your repository and it will find all subgraph-config.json files and automatically convert them into schema-settings.json files:

Bash
dnx ChilliCream.Nitro.CommandLine fusion migrate subgraph-config

Note: If you can't use .NET 10 / dnx you can also install ChilliCream.Nitro.CommandLine via dotnet tool install and then invoke it via dotnet nitro ....

If you need to do this conversion manually: Create a schema-settings.json file next to each subgraph-config.json with the following changes:

{
- "subgraph": "products",
- "http": {
- "baseAddress": "http://products/graphql"
- }
+ "version": "1.0.0",
+ "name": "products",
+ "transports": {
+ "http": {
+ "url": "http://products/graphql"
+ }
+ }
}

Note: By default the Fusion v2 composition assumes your subgraph is compatible with the latest features. By adding "version": "1.0.0" we tell the composition that this is a legacy (Fusion v1) subgraph, which relaxes certain validations like @shareable and re-creates inferences that were present in Fusion v1, like fields ending in ById being inferred as @lookup.

If your subgraph is using a version older than the latest HotChocolate v15 or your subgraph uses an entirely different technology, you also need to disable variable batching in schema-settings.json.

{
"version": "1.0.0",
"name": "products",
"transports": {
"http": {
- "url": "http://products/graphql"
+ "url": "http://products/graphql",
+ "capabilities": {
+ "batching": {
+ "variableBatching": false
+ }
+ }
}
}
}

Environment-specific configuration

Fusion v1 let you set environment-specific values from the pipeline with fusion subgraph config set:

Bash
dotnet fusion subgraph config set http \
--url https://dev.example.com/graphql \
-c subgraph.fsp

Fusion v2 moves these values into schema-settings.json. Replace anything that varies between environments with a {{PLACEHOLDER}} token, then list the per-environment values under a top-level environments section:

{
"version": "1.0.0",
"name": "products",
"transports": {
"http": {
- "url": "https://example.com/graphql"
+ "url": "{{URL}}"
}
- }
+ },
+ "environments": {
+ "dev": {
+ "URL": "https://dev.example.com/graphql"
+ },
+ "prod": {
+ "URL": "https://prod.example.com/graphql"
+ }
+ }
}

Composition resolves the placeholders against a chosen environment. Pass --environment <environment> to nitro fusion compose to select one explicitly, or rely on nitro fusion publish, which derives the environment from its --stage value. When publishing through Nitro, the keys under environments therefore need to match the stage names defined in Nitro.

Update subgraph

The concept of batch resolvers like productByIds(ids: [ID!]!) no longer exists in Fusion v2. Batching is done on the transport level through variable and request batching. This means singular fields like Query.productById(id: ID!): Product are invoked with a list of IDs instead of a plural Query.productsById(ids: [ID!]!): [Product!] field. Check out this GitHub issue for details on this decision.

Since you don't want multiple invocations of the Query.productById field during a single request to hit the database multiple times, you need to ensure your Query root fields and [NodeResolver] implementations (powering the Query.node(id: ID!): Node field) are using DataLoader. This is a best practice and ensures the performance of your server does not degrade in comparison to the previous batching fields.

If an entity currently only has batch Query root fields in your subgraph, you'll also have to add a singular field:

type Query {
productsById(ids: [ID!]!): [Product!] @lookup @internal
+ productById(id: ID!): Product @lookup @internal
}

Variable and request batching aren't enabled by default, so you also need to update your Program.cs to enable it:

- app.MapGraphQL();
+ app.MapGraphQL().WithOptions(new GraphQLServerOptions { EnableBatching = true });

If you want to, you can also now migrate the subgraph to Hot Chocolate v16, but it's not required at this point.

Migrate pipelines

Migrate one subgraph repository at a time. Throughout this stage your gateway stays on v15 and keeps serving traffic; you cut it over to v16 only after every subgraph publishes a .far archive.

Note: In Fusion v15 a subgraph pipeline composes a Fusion gateway package (.fgp) and publishes it to Nitro as the latest archive. In Fusion v16 the equivalent artifact is the Fusion archive (.far).

The change to each subgraph pipeline is small: keep the v15 compose step that produces the .fgp, but instead of publishing the .fgp directly, publish a .far with that .fgp embedded via --legacy-v1-archive. This keeps the .fgp fresh for the running v15 gateway and makes the .far available for the v16 cut-over.

Two pipelines in a typical subgraph repository need this change:

  • the deployment pipeline that publishes the subgraph's archive to Nitro, and
  • the PR validation pipeline that checks the composed schema for breaking changes.

In both, the existing v15 download and compose steps stay in place; only the final Nitro command changes.

Deployment pipeline

In practice this means three changes to your existing deployment pipeline:

  1. Add a step in the build job that uploads the source schema to Nitro, so the v16 publish can reference it.
  2. Keep the v15 compose step in the deploy job. It is still responsible for producing an up-to-date .fgp.
  3. Replace the final dotnet nitro fusion-configuration publish commit with dotnet nitro fusion publish and pass the freshly composed .fgp via --legacy-v1-archive.

Below is the existing v15 pipeline for reference:

Bash
# BUILD JOB
dotnet run --project ./src/SubgraphA -- schema export --output schema.graphql
dotnet fusion subgraph pack -w ./src/SubgraphA
# DEPLOY JOB
dotnet fusion subgraph config set http \
--url <subgraph-url> \
-c ${{ github.workspace }}/subgraph/subgraph-a.fsp
dotnet nitro fusion-configuration publish begin \
--tag <tag> \
--api-id <api-id> \
--subgraph-name subgraph-a \
--stage <stage> \
--api-key <api-key>
dotnet nitro fusion-configuration publish start \
--api-key <api-key>
dotnet nitro fusion-configuration download \
--api-id <api-id> \
--stage <stage> \
--output-file ./gateway.fgp \
--api-key <api-key>
dotnet fusion compose \
-p ./gateway.fgp \
--enable-nodes \
-s ${{ github.workspace }}/subgraph
dotnet nitro fusion-configuration publish commit \
--configuration ./gateway.fgp \
--api-key <api-key>

Upload the source schema in the build job

Add a step to the build job that uploads the exported source schema to Nitro. The tag is later used by the publish step to find the matching upload.

YAML
- uses: ChilliCream/nitro-fusion-upload@v16
with:
tag: <tag>
api-id: <api-id>
api-key: <api-key>
source-schema-files: |
./src/SubgraphA/schema.graphql

Note: The dotnet fusion subgraph pack step is still required while the v15 compose step runs in the deploy job, since v15 composition consumes the .fsp archive. It can be removed once the subgraph is migrated to v16 and the v15 compose step is dropped (see Cleanup).

Replace publish commit with nitro fusion publish in the deploy job

In the deploy job, leave the existing v15 commands that download the latest .fgp and run v15 composition untouched. Only the trailing dotnet nitro fusion-configuration publish commit is removed:

- dotnet nitro fusion-configuration publish commit \
- --configuration ./gateway.fgp \
- --api-key <api-key>

Replace it with dotnet nitro fusion publish, passing the freshly composed gateway.fgp via --legacy-v1-archive. This composes a new .far, embeds the .fgp inside it, and uploads the result as the latest archive.

YAML
- uses: ChilliCream/nitro-fusion-publish@v16
with:
tag: <tag>
stage: <stage>
api-id: <api-id>
api-key: <api-key>
legacy-v1-archive: ./gateway.fgp
source-schemas: |
subgraph-a

Note: dotnet nitro fusion publish should run after the subgraph application has been deployed. Once it succeeds, the new archive becomes the latest in Nitro and the gateway will start routing traffic against the new schema, so the subgraph must already be reachable at that URL.

Note: --legacy-v1-archive is only required during the transition. Once every subgraph has been migrated to v16 and the gateway has been cut over to consume .far directly, the v15 compose step and the --legacy-v1-archive option can be removed (see Cleanup).

PR validation pipeline

In addition to the deployment pipeline, most subgraph repositories have a PR validation pipeline that downloads the latest archive, runs composition with the proposed change, and verifies that the composed schema introduces no breaking changes. Below are the relevant v15 steps for reference:

Bash
dotnet run --project ./src/SubgraphA -- schema export --output schema.graphql
dotnet fusion subgraph pack -w ./src/SubgraphA
dotnet nitro fusion-configuration download \
--api-id <api-id> \
--stage <stage> \
--output-file ./gateway.fgp \
--api-key <api-key>
dotnet fusion compose \
--package-file ./gateway.fgp \
--enable-nodes \
--subgraph-package-file ./src/SubgraphA/subgraph-a.fsp
dotnet nitro fusion-configuration validate \
--stage <stage> \
--api-id <api-id> \
--configuration ./gateway.fgp \
--api-key <api-key>

As with the deployment pipeline, the v15 download and compose steps stay in place during the transition so the v15 composition path keeps being validated. Only the final dotnet nitro fusion-configuration validate is replaced by dotnet nitro fusion validate. Pass the freshly composed gateway.fgp via --legacy-v1-archive so the validation also covers the embedded v15 archive:

- dotnet nitro fusion-configuration validate \
- --stage <stage> \
- --api-id <api-id> \
- --configuration ./gateway.fgp \
- --api-key <api-key>
YAML
- uses: ChilliCream/nitro-fusion-validate@v16
with:
stage: <stage>
api-id: <api-id>
api-key: <api-key>
legacy-v1-archive: ./gateway.fgp
source-schema-files: |
./src/SubgraphA/schema.graphql

Migrate subgraph to v16

Once a subgraph is publishing a .far, you can migrate the subgraph project itself to Hot Chocolate v16 at any time. This is optional and independent of the gateway cut-over, but it lets you drop the legacy compatibility mode and use the full Fusion v2 feature set.

Start by working through the Hot Chocolate 15 to 16 migration guide for the subgraph project. Once that is done, apply the steps below.

Apply the source schema defaults

Call AddSourceSchemaDefaults() when configuring the GraphQL server:

builder.Services
.AddGraphQLServer()
+ .AddSourceSchemaDefaults()
// ...

This registers the schema as a Fusion source schema, which:

  • enables variable and request batching automatically, so you can remove the manual batching change from the Update subgraph step,
  • applies @shareable to framework-provided types such as Relay connections, the PageInfo type, and the Query.node field,
  • applies @serializeAs to the built-in scalars.

Remove the legacy compatibility mode

Remove "version": "1.0.0" from schema-settings.json:

{
- "version": "1.0.0",
"name": "products",
// ...
}

Without "version": "1.0.0" the composition treats the subgraph as a Fusion v2 subgraph: the full validations are enforced and the Fusion v1 inferences (such as fields ending in ById being treated as @lookup) are no longer applied. As a result, a few things that used to be inferred now have to be explicit.

Annotate By<Field> lookup fields like productById(id: ID!): Product with [Lookup] (@lookup):

[QueryType]
public static class Query
{
+ [Lookup]
public static Product GetProductById(int id)
=> // ...
}

Missing @lookup annotations will usually manifest as UNSATISFIABLE errors in the composition. See Entities and lookups for details.

If your graph has overlapping fields, i.e. multiple subgraphs providing the same field, you now also have to explicitly mark those fields as shareable from both sides.

[ObjectType]
public class Product
{
+ [Shareable]
public string Name { get; set; }
}

Missing @shareable annotations will usually manifest as INVALID_FIELD_SHARING errors in the composition. See Field ownership and sharing for details.

See Composition for guidance on composition behavior and an explanation of any other errors you might encounter, including what they mean and how to fix them.

Upgrade the gateway

Start by installing the latest 16.x.x version of all of the HotChocolate.Fusion.* packages referenced by your project. The gateway runtime now ships in a single ASP.NET Core meta-package, HotChocolate.Fusion.AspNetCore, which includes the execution engine, the type system, and the ASP.NET Core integration. This means you can replace your existing references to HotChocolate.AspNetCore and HotChocolate.Fusion with a single reference to HotChocolate.Fusion.AspNetCore:

-<PackageReference Include="HotChocolate.AspNetCore" Version="15.x.x" />
-<PackageReference Include="HotChocolate.Fusion" Version="15.x.x" />
+<PackageReference Include="HotChocolate.Fusion.AspNetCore" Version="16.x.x" />

Breaking changes

Things that have been removed or had a change in behavior that may cause your code not to compile or lead to unexpected behavior at runtime if not addressed.

AddFusionGatewayServer renamed to AddGraphQLGatewayServer

The entry point that adds a Fusion gateway to the service collection has been renamed and now lives in the Microsoft.Extensions.DependencyInjection namespace.

-builder.Services.AddFusionGatewayServer();
+builder.Services.AddGraphQLGatewayServer();

The builder type returned by AddGraphQLGatewayServer is now IFusionGatewayBuilder instead of the concrete FusionGatewayBuilder. All of the configuration extension methods now hang off this interface.

CoreBuilder is gone — methods now hang off IFusionGatewayBuilder directly

In v15, the Fusion gateway builder exposed a CoreBuilder property of type IRequestExecutorBuilder that you used to reach Hot Chocolate's core configuration APIs (validation rules, error filters, etc.).

In v16 there is no separate underlying request executor builder. The Fusion gateway is configured exclusively via IFusionGatewayBuilder, and all relevant Hot Chocolate APIs (such as DisableIntrospection, AddErrorFilter, AddSha256DocumentHashProvider, etc.) are exposed directly on IFusionGatewayBuilder as Fusion-specific extension methods.

-gatewayBuilder.CoreBuilder.DisableIntrospection();
+gatewayBuilder.DisableIntrospection();

ModifyFusionOptions split into ModifyOptions, ModifyRequestOptions and ModifyPlannerOptions

FusionOptions and RequestExecutorOptions no longer exist as Fusion configuration surfaces. The settings have been split across three dedicated option types and three matching Modify* methods on IFusionGatewayBuilder:

v15 surfacev16 surface
ModifyFusionOptionsModifyOptions (cache sizes, error handling, …)
ModifyRequestOptionsModifyRequestOptions (per-request settings)
ModifyPlannerOptions (planner guardrails)
-gatewayBuilder
- .ModifyFusionOptions(o =>
- {
- o.AllowQueryPlan = true;
- o.IncludeDebugInfo = true;
- })
- .ModifyRequestOptions(o =>
- {
- o.ExecutionTimeout = TimeSpan.FromSeconds(30);
- o.PersistedOperations.OnlyAllowPersistedDocuments = false;
- o.IncludeExceptionDetails = true;
- });
+gatewayBuilder
+ .ModifyOptions(o =>
+ {
+ o.OperationDocumentCacheSize = 200;
+ o.OperationExecutionPlanCacheSize = 100;
+ })
+ .ModifyRequestOptions(o =>
+ {
+ o.ExecutionTimeout = TimeSpan.FromSeconds(30);
+ o.PersistedOperations.OnlyAllowPersistedDocuments = false;
+ o.IncludeExceptionDetails = true;
+ o.AllowOperationPlanRequests = true;
+ });

Internal directives hidden from schema endpoint

Previously, the /graphql/schema.graphql endpoint was returning the schema containing internal directives like @authorize. Starting with v16 the endpoint no longer includes internal directives by default.

If you need to retain the previous behavior, set DisableInternalDirectives to true through ModifyOptions. This treats every directive as public, even directives that explicitly call Internal() and regardless of DefaultDirectiveVisibility:

C#
builder.Services
.AddGraphQLServer()
.ModifyOptions(o => o.DisableInternalDirectives = true);

Be aware that internal directives may carry sensitive information (for example, authorization policies attached via @authorize). Only enable this if you understand and accept that risk.

Cache configuration

In v15, the operation cache acted as the cache for operation plans. v16 introduces a dedicated operation plan cache. Both document and operation plan cache are now configured on the gateway builder via ModifyOptions instead of as global services on the IServiceCollection:

-builder.Services.AddDocumentCache(capacity: 200);
-builder.Services.AddOperationCache(capacity: 100);
builder.Services
.AddGraphQLGatewayServer()
+ .ModifyOptions(o =>
+ {
+ o.OperationDocumentCacheSize = 200;
+ o.OperationExecutionPlanCacheSize = 100;
+ });

If your application contains multiple Fusion gateways, the cache configuration has to be repeated for each one as the configuration is now scoped to a particular gateway.

Document hash provider configuration

Document hash providers are no longer registered through the IServiceCollection. Move the call to IFusionGatewayBuilder instead:

-builder.Services.AddSha256DocumentHashProvider();
builder.Services
.AddGraphQLGatewayServer()
+ .AddSha256DocumentHashProvider();

The same applies to AddMD5DocumentHashProvider and AddSha1DocumentHashProvider.

Eager initialization by default

Previously, the Fusion gateway constructed the schema and the request executor on the first request. To get eager initialization, you had to opt in via InitializeOnStartup on the underlying CoreBuilder.

In v16, eager initialization is the default. The schema and the request executor are constructed during application startup, before Kestrel begins accepting traffic. Schema errors surface immediately when you start the gateway, rather than only when the first request arrives.

If you used InitializeOnStartup, remove it. If you also passed a warmup delegate, migrate it to AddWarmupTask:

-gatewayBuilder.CoreBuilder
- .InitializeOnStartup(warmup: (executor, ct) => /* ... */);
+gatewayBuilder.AddWarmupTask((executor, ct) => /* ... */);

If you really need lazy initialization, opt out via ModifyOptions:

C#
gatewayBuilder.ModifyOptions(o => o.LazyInitialization = true);

Server options now configured via ModifyServerOptions

GraphQLServerOptions (GET requests, multipart uploads, batching, schema requests, the embedded Nitro tool, etc.) are now configured at the schema level using ModifyServerOptions on IFusionGatewayBuilder instead of per-endpoint:

-app.MapGraphQL().WithOptions(new GraphQLServerOptions
-{
- EnableBatching = true,
- Tool = { Enable = false }
-});
+gatewayBuilder.ModifyServerOptions(o =>
+{
+ o.Batching = AllowedBatching.All;
+ o.Tool.Enable = false;
+});
+
+app.MapGraphQL();

Per-endpoint overrides are still supported but now use a delegate pattern instead of an object initializer:

C#
app.MapGraphQL().WithOptions(o => o.EnableGetRequests = false);

Batching is now disabled by default

In v15, request batching was enabled by default (EnableBatching = true). In v16, batching is disabled by default as a security measure. The EnableBatching property has been replaced by Batching, which uses the AllowedBatching flags enum for fine-grained control:

-options.EnableBatching = true;
+options.Batching = AllowedBatching.All;

If you were relying on the previous default, you need to explicitly enable batching:

C#
gatewayBuilder.ModifyServerOptions(o => o.Batching = AllowedBatching.All);

A new MaxBatchSize property limits the number of operations in a single batch. The default is 1024. Set it to 0 for unlimited.

Configuration provider API

The configuration provider abstractions used to load and watch the Fusion gateway configuration document have been redesigned around IFusionConfigurationProvider.

IObservable<GatewayConfiguration> replaced by IFusionConfigurationProvider

The old IObservable<GatewayConfiguration> source has been replaced by the new IFusionConfigurationProvider interface, which combines IObservable<FusionConfiguration> with IAsyncDisposable and exposes the latest configuration via a Configuration property. The configuration payload type is now FusionConfiguration (a DocumentNode plus a JsonDocumentOwner for schema settings) instead of GatewayConfiguration.

-public class CustomConfigurationProvider : IObservable<GatewayConfiguration>
-{
- public IDisposable Subscribe(IObserver<GatewayConfiguration> observer) => /* ... */;
-}
+public class CustomConfigurationProvider : IFusionConfigurationProvider
+{
+ public FusionConfiguration? Configuration => /* latest snapshot */;
+
+ public IDisposable Subscribe(IObserver<FusionConfiguration> observer) => /* ... */;
+
+ public ValueTask DisposeAsync() => ValueTask.CompletedTask;
+}

RegisterGatewayConfiguration → AddConfigurationProvider

-gatewayBuilder.RegisterGatewayConfiguration(sp => new CustomConfigurationProvider());
+gatewayBuilder.AddConfigurationProvider(sp => new CustomConfigurationProvider());

ConfigureFromFile → AddFileSystemConfiguration

-gatewayBuilder.ConfigureFromFile("gateway.fgp");
+gatewayBuilder.AddFileSystemConfiguration("gateway.fgp");

The watchFileForUpdates parameter is gone — file watching is the default behavior of the file-system configuration provider.

ConfigureFromDocument → AddInMemoryConfiguration

-gatewayBuilder.ConfigureFromDocument(documentNode);
+gatewayBuilder.AddInMemoryConfiguration(documentNode);

AddInMemoryConfiguration also accepts an optional JsonDocumentOwner for the schema settings.

Nitro integration

The packages have been restructured in v16. Versions now align with the rest of the platform, so you are migrating from 1.x to 16.x. For the full migration guide covering all package renames, API changes, and complete before/after examples, see Migrating Nitro from 1 to 16.

The key changes for Fusion projects:

  • Package rename: ChilliCream.Nitro.Core becomes ChilliCream.Nitro.GraphQL, ChilliCream.Nitro.Telemetry becomes ChilliCream.Nitro.OpenTelemetry, and Azure packages are consolidated into ChilliCream.Nitro.Azure.
  • ConfigureFromCloud() is replaced by AddNitro().AddDefaults() on the service collection.
  • Per-gateway feature options are configured via ModifyNitroOptions() on the gateway builder.
  • AddNitroExporter() is replaced by AddOpenTelemetry() on the INitroBuilder.
  • Asset cache is now configured globally on INitroBuilder instead of per-gateway.
  • AddDefaults() is a source-generated method that wires up the default integration when the correct packages are referenced.

Note: If you are self-hosting the Nitro backend, make sure to update it to the latest version as well. 10.1.0 is the minimum version required to work with the ChilliCream.Nitro.* packages.

Before

C#
builder.Services
.AddFusionGatewayServer()
.ConfigureFromCloud(o =>
{
o.ApiId = "...";
o.ApiKey = "...";
o.Stage = "...";
});

After

C#
builder.Services
.AddNitro(o =>
{
o.ApiId = "...";
o.ApiKey = "...";
o.Stage = "...";
})
.AddDefaults();
builder.Services.AddGraphQLGatewayServer();

Diagnostic listener API redesigned

Fusion diagnostics were redesigned in v16. The high-level ExecuteFederatedQuery, ResolveError, ResolveByKeyBatchError, QueryPlanExecutionError, and SubgraphRequestError hooks are gone. The new API is execution-stage specific.

BeforeAfter
HotChocolate.Fusion.Execution.Diagnostic.IFusionDiagnosticEventListenerHotChocolate.Fusion.Diagnostics.IFusionExecutionDiagnosticEventListener
FusionDiagnosticEventListener (base class)FusionExecutionDiagnosticEventListener (base class)
ExecuteFederatedQuery(IRequestContext)ExecuteRequest(RequestContext)
QueryPlanExecutionError(Exception)PlanOperationError(RequestContext, string operationId, Exception)
ResolveError(Exception) / ResolveByKeyBatchError(Exception)ExecutionNodeError(OperationPlanContext, ExecutionNode, Exception)
SubgraphRequestError(string subgraphName, Exception)SourceSchemaTransportError(OperationPlanContext, ExecutionNode, string schemaName, Exception)
-using HotChocolate.Fusion.Execution.Diagnostic;
+using HotChocolate.Fusion.Diagnostics;
+using HotChocolate.Fusion.Execution;
+using HotChocolate.Fusion.Execution.Nodes;
-public class DiagnosticEventListener : FusionDiagnosticEventListener
+public class DiagnosticEventListener : FusionExecutionDiagnosticEventListener
{
- public override IDisposable ExecuteFederatedQuery(IRequestContext context)
- => base.ExecuteFederatedQuery(context);
+ public override IDisposable ExecuteRequest(RequestContext context)
+ => base.ExecuteRequest(context);
- public override void QueryPlanExecutionError(Exception exception)
- => base.QueryPlanExecutionError(exception);
+ public override void PlanOperationError(RequestContext context, string operationId, Exception error)
+ => base.PlanOperationError(context, operationId, error);
- public override void SubgraphRequestError(string subgraphName, Exception exception)
- => base.SubgraphRequestError(subgraphName, exception);
+ public override void SourceSchemaTransportError(OperationPlanContext context, ExecutionNode node, string schemaName, Exception error)
+ => base.SourceSchemaTransportError(context, node, schemaName, error);
}

The new interface also exposes additional execution-stage hooks (AddedOperationPlanToCache, SourceSchemaStoreError, SubscriptionEventError).

Scoped duration and error hooks

In v15, the only scoped (IDisposable-returning) hook on IFusionDiagnosticEventListener was ExecuteFederatedQuery, which wrapped the entire federated request. v16 broadens the scope significantly: each major execution stage and each individual execution node has its own IDisposable-returning hook on IFusionExecutionDiagnosticEventListener, so you can measure the duration of, for example, planning an operation or a single subgraph fetch in isolation. The error hooks have likewise been redesigned around the new node-based execution model.

v15v16
ExecuteFederatedQuery(IRequestContext)ExecuteRequest(RequestContext)
PlanOperation(RequestContext, string operationPlanId)
ExecuteOperation(RequestContext)
ExecuteOperationNode(OperationPlanContext, OperationExecutionNode, string schemaName)
ExecuteOperationBatchNode(OperationPlanContext, OperationBatchExecutionNode, string schemaName)
ExecuteNodeFieldNode(OperationPlanContext, NodeFieldExecutionNode)
ExecuteIntrospectionNode(OperationPlanContext, IntrospectionExecutionNode)
ExecuteSubscription(RequestContext, ulong subscriptionId)
ExecuteSubscriptionNode(OperationPlanContext, ExecutionNode, string schemaName, ulong subscriptionId)
OnSubscriptionEvent(OperationPlanContext, ExecutionNode, string schemaName, ulong subscriptionId)
QueryPlanExecutionError(Exception)PlanOperationError(RequestContext, string operationId, Exception)
ResolveError(Exception)ExecutionNodeError(OperationPlanContext, ExecutionNode, Exception)
ResolveByKeyBatchError(Exception)ExecutionNodeError(OperationPlanContext, ExecutionNode, Exception)
SubgraphRequestError(string subgraphName, Exception)SourceSchemaTransportError(OperationPlanContext, ExecutionNode, string schemaName, Exception)

To time individual stages of the request pipeline (parsing, validation, variable coercion) you previously had to implement Hot Chocolate's core IExecutionDiagnosticEventListener separately and register it alongside the Fusion-specific listener. In v16 these stages have been folded into IFusionExecutionDiagnosticEventListener itself, so you can remove your IExecutionDiagnosticEventListener implementations and move the overrides (for example ParseDocument, ValidateDocument, CoerceVariables) onto your FusionExecutionDiagnosticEventListener subclass instead.

The dedicated SubscriptionTransportError(...) hook from the v15 Fusion diagnostics API is also no longer exposed separately. Subscription transport failures now flow through SourceSchemaTransportError(...) like any other source-schema transport error.

IRequestContext

Hot Chocolate has removed the IRequestContext abstraction in favor of the concrete RequestContext class. This applies to the Fusion diagnostic API as well:

BeforeAfter
context.DocumentIdcontext.OperationDocumentInfo.Id.Value
context.Documentcontext.OperationDocumentInfo.Document
context.DocumentHashcontext.OperationDocumentInfo.Hash.Value
context.ValidationResultcontext.OperationDocumentInfo.IsValidated
context.IsCachedDocumentcontext.OperationDocumentInfo.IsCached
context.IsPersistedDocumentcontext.OperationDocumentInfo.IsPersisted

If you have a custom request middleware on the Fusion pipeline:

public class CustomRequestMiddleware
{
- public async ValueTask InvokeAsync(IRequestContext context)
+ public async ValueTask InvokeAsync(RequestContext context)
{
- string documentId = context.DocumentId;
+ string documentId = context.OperationDocumentInfo.Id.Value;
await _next(context).ConfigureAwait(false);
}
}

Clearer separation between schema and application services

Hot Chocolate has long maintained a second IServiceProvider for schema services, separate from the application service provider where you register your services and configuration. This schema service provider is scoped to a particular schema and contains all of the internal services for the gateway (diagnostic listeners, error filters, HTTP request interceptors, …).

To access application services within schema services like diagnostic event listeners or error filters, the v15 implementation used a combined service provider. In v16, the Fusion gateway uses the schema service provider exclusively — application services must now be explicitly cross-registered to be accessible.

builder.Services.AddSingleton<MyService>();
builder.Services.AddGraphQLGatewayServer()
+ .AddApplicationService<MyService>()
.AddDiagnosticEventListener<MyDiagnosticEventListener>();
public class MyDiagnosticEventListener(MyService service) : FusionExecutionDiagnosticEventListener;

If you're using any of the following Fusion configuration APIs, ensure that the application services required for their activation are registered via AddApplicationService<T>():

  • AddHttpRequestInterceptor
  • AddErrorFilter
  • AddDiagnosticEventListener
  • AddOperationPlannerInterceptor

If you need to access the application service provider from within the schema service provider, use:

C#
IServiceProvider applicationServices = schemaServices.GetRootServiceProvider();

Experimental @semanticNonNull support removed

Hot Chocolate v15 included experimental support for the @semanticNonNull directive, which let you mark fields as semantically non-null while still returning null (rather than propagating to the parent) when a resolver errored. This feature has been removed in v16 in favor of the onError proposal.

If you previously opted in to this feature on the Fusion gateway, remove the option:

gatewayBuilder
.ModifyOptions(o =>
{
- o.EnableSemanticNonNull = true;
});

If you still need to keep the behavior of not propagating nulls for errors on non-null fields, set the DefaultErrorHandlingMode to ErrorHandlingMode.Null:

C#
gatewayBuilder.ModifyRequestOptions(o => o.DefaultErrorHandlingMode = ErrorHandlingMode.Null);

Clients that still need a schema with @semanticNonNull annotations

If you have a client that still relies on the schema being annotated with @semanticNonNull, you have a few options to obtain such a schema.

Schema snapshot tests

If you produce a schema string for snapshot tests via ISchemaDefinition.ToString(), switch to SchemaFormatter with RewriteToSemanticNonNull enabled:

C#
string schemaStr = SchemaFormatter.FormatAsString(
schema,
new SchemaFormatterOptions { RewriteToSemanticNonNull = true });

Downloading the schema from the gateway

If you're using MapGraphQLSchema() to expose the gateway schema at /graphql/schema, you can additionally call MapGraphQLSemanticNonNullSchema() to expose a variant annotated with @semanticNonNull at /graphql/semantic-non-null-schema.graphql:

C#
app.MapGraphQLSchema();
app.MapGraphQLSemanticNonNullSchema();

Noteworthy changes

Concurrent execution gate

Hot Chocolate v16 introduces a concurrency gate that limits how many GraphQL operations execute at the same time. The gate sits in the request pipeline just before operation execution and applies uniformly to queries, mutations, subscription handshakes, and each subscription event.

For the Fusion gateway, configure the limit through ModifyServerOptions:

C#
gatewayBuilder.ModifyServerOptions(o => o.MaxConcurrentExecutions = 128);

The default is 64. Operations that arrive while the gate is full queue up and run as slots free. Set the limit to null to disable the gate entirely.

Every execution is bounded by the ExecutionTimeout option (default 30 seconds). The budget covers both the time an execution spends waiting for a concurrency slot and the time it spends running.

Parser limits

The parser now enforces a maximum recursion depth of 200 by default, a maximum of 4 directives per location, and a fragment visit budget of 1,000 per operation. These limits also apply to documents handled by the Fusion gateway. If your operations legitimately exceed these limits, raise them via ModifyParserOptions / ModifyValidationOptions:

C#
gatewayBuilder
.ConfigureValidation((_, b) => b.ModifyOptions(o => o.MaxAllowedFragmentVisits = 5_000));

RunWithGraphQLCommandsAsync returns exit code

RunWithGraphQLCommandsAsync and RunWithGraphQLCommands now return exit codes (Task<int> and int respectively). Update your Program.cs if you forward these to the host:

-await app.RunWithGraphQLCommandsAsync(args);
+return await app.RunWithGraphQLCommandsAsync(args);

Aspire

The Aspire integration changed in v16. There is no separate AddFusionGateway resource anymore. The gateway and subgraphs are now regular Aspire projects.

First, update the Aspire.AppHost.Sdk and the Aspire.Hosting.AppHost package to 13.x:

- <Sdk Name="Aspire.AppHost.Sdk" Version="x.x.x"/>
+ <Sdk Name="Aspire.AppHost.Sdk" Version="13.x.x"/>
<ItemGroup>
- <PackageReference Include="Aspire.Hosting.AppHost" Version="x.x.x" />
- <PackageReference Include="HotChocolate.Fusion.Aspire" Version="15.x.x" />
+ <PackageReference Include="Aspire.Hosting.AppHost" Version="13.x.x" />
+ <PackageReference Include="HotChocolate.Fusion.Aspire" Version="16.x.x" />
</ItemGroup>

Then update the AppHost setup. Add the GraphQL orchestrator, tell Aspire where to get each subgraph schema, and reference those subgraphs from the gateway:

-var products = builder.AddProject<Projects.Products>("products");
-var reviews = builder.AddProject<Projects.Reviews>("reviews");
-var accounts = builder.AddProject<Projects.Accounts>("accounts");
-
-builder
- .AddFusionGateway<Projects.Gateway>("gateway")
- .WithSubgraph(products)
- .WithSubgraph(reviews)
- .WithSubgraph(accounts);
-
-builder.Build().Compose().Run();
+builder.AddGraphQLOrchestrator();
+
+var products = builder.AddProject<Projects.Products>("products")
+ .WithGraphQLSchemaEndpoint();
+
+var reviews = builder.AddProject<Projects.Reviews>("reviews")
+ .WithGraphQLSchemaEndpoint();
+
+var accounts = builder.AddProject<Projects.Accounts>("accounts")
+ .WithGraphQLSchemaEndpoint();
+
+builder
+ .AddProject<Projects.Gateway>("gateway")
+ .WithGraphQLSchemaComposition()
+ .WithReference(products)
+ .WithReference(reviews)
+ .WithReference(accounts);
+
+builder.Build().Run();

builder.AddGraphQLOrchestrator() installs the startup hook that runs schema discovery and composition. You no longer call .Compose(). builder.Build().Run() is enough.

WithGraphQLSchemaEndpoint() downloads the subgraph schema from /graphql/schema.graphql at startup. If your schema endpoint uses a different path, pass it explicitly:

C#
builder
.AddProject<Projects.Products>("products")
.WithGraphQLSchemaEndpoint(path: "/schema.graphql");

If you keep schema files on disk, use WithGraphQLSchemaFile() instead. It looks for schema.graphqls in the subgraph project directory by default:

C#
builder
.AddProject<Projects.Products>("products")
.WithGraphQLSchemaFile(fileName: "./dir/schema.graphqls");

To create that file automatically when a subgraph starts, call ExportSchemaOnStartup() on the subgraph's IRequestExecutorBuilder.

Pass composition options to WithGraphQLSchemaComposition:

C#
builder
.AddProject<Projects.Gateway>("gateway")
.WithGraphQLSchemaComposition(
settings: new GraphQLCompositionSettings
{
EnableGlobalObjectIdentification = true
});

Each subgraph also needs an Aspire environment in schema-settings.json. This is the local GraphQL endpoint the composed gateway configuration uses when it runs under Aspire:

{
"name": "my-subgraph",
+ "transports": {
+ "http": {
+ "url": "{{API_URL}}"
+ }
+ },
+ "environments": {
+ "Aspire": {
+ "API_URL": "http://localhost:5000/graphql"
+ }
+ }
}

Cleanup

Once the gateway has been cut over to v16 and every subgraph publishes a .far, the dual-format bridge is no longer needed. In every subgraph pipeline you can now drop all dotnet fusion usages (subgraph pack, subgraph config set, compose) and all dotnet nitro fusion-configuration * commands, since the equivalent work is now done by dotnet nitro fusion * (upload, publish, validate). Also remove the --legacy-v1-archive option from those dotnet nitro fusion publish / validate steps.

Since dotnet fusion is no longer used, you can remove any reference to the HotChocolate.Fusion.CommandLine package from your pipelines and from ./.config/dotnet-tools.json.

You can also fully replace ChilliCream.Nitro.CLI with ChilliCream.Nitro.CommandLine.

Note: Hold off until the v16 gateway has been running in production long enough that a rollback to v15 is off the table. Once the v15 compose step is gone the .fgp is no longer refreshed, and a rollback would mean restoring these steps first. Cleanup is independent per subgraph, so there is no need to do all repositories at once.

Finally, if you haven't done so already, each subgraph can now independently switch to Hot Chocolate v16 at its own pace.

Last updated on May 18, 2026 by Michael Staib