Mert Özen Further With Every Line
Backend gRPC and GraphQL — #12

What Changed in gRPC and GraphQL: From .NET Core 3.0 to .NET 11 and Hot Chocolate 13 to 16

Mert Özen Nov 14, 2026 10 min 5 views
What Changed in gRPC and GraphQL: From .NET Core 3.0 to .NET 11 and Hot Chocolate 13 to 16

The first things to check in an old gRPC or GraphQL project are Grpc.Core usage and the Hot Chocolate major version: Grpc.Core has been in maintenance mode since 2021, and Hot Chocolate 16 rewrote the scalar API. We show what grpc-dotnet brought from .NET Core 3.0 to .NET 11 and Hot Chocolate from 13 to 16, the same ISBN scalar on both versions, and the September 2025 GraphQL specification, with real output.

Version note: The code in this post was built and run with .NET 10; on the gRPC side Grpc.Net.Client 2.85 and, for comparison, Grpc.Core 2.46.6 were used, and on the GraphQL side Hot Chocolate 15.1.21 and 16.7.5.

Guess First

In a project you've inherited, Inventory is called like this:

// old: Grpc.Core, on top of the native C-core library
Channel channel = new($"{host}:5301", ChannelCredentials.Insecure);
InventoryService.InventoryServiceClient client = new(channel);
ReserveStockResponse reply = await client.ReserveStockAsync(request, deadline: DateTime.UtcNow.AddSeconds(5));
Console.WriteLine($"Grpc.Core: {reply.ReservationId[..10]}...");
await channel.ShutdownAsync();

The project is moving to .NET 10. Does this code compile? Does it run? And if it runs, why touch it?

The answer: it compiles, and it ran on x64 Linux. But that doesn't change the fact that Grpc.Core has been in maintenance mode since 2021 and its last release came out in January 2023. This post pulls together how the things we built with .NET 10 and Hot Chocolate 16 throughout the series looked in older versions, and what to watch for when reading an old project.

gRPC on .NET: From Grpc.Core to grpc-dotnet

There have been two gRPC implementations in the .NET world. The first is Grpc.Core: a library wrapping Google's gRPC core written in C (C-core). It carries a native library (libgrpc_csharp_ext) for each platform and speaks HTTP/2 (HyperText Transfer Protocol 2) in its own core. The second is grpc-dotnet, which arrived with .NET Core 3.0: on the server side it uses Kestrel through Grpc.AspNetCore, and on the client side the HTTP/2 support of HttpClient through Grpc.Net.Client; it has no native dependency.

In May 2021 the gRPC project's official blog announced that Grpc.Core was entering maintenance mode and that grpc-dotnet was the recommended implementation. The deprecation date was first announced as May 2022, then May 2023, and later extended to at least October 2024. The last release on NuGet is 2.46.6, dated 11 January 2023. No new features are coming, and the native library only exists for the platforms it was prebuilt for inside the package.

The new version is almost the same number of lines:

// new: Grpc.Net.Client, on top of HttpClient and .NET's own HTTP/2 stack
using GrpcChannel channel = GrpcChannel.ForAddress($"http://{host}:5301");
InventoryService.InventoryServiceClient client = new(channel);
ReserveStockResponse reply = await client.ReserveStockAsync(request, deadline: DateTime.UtcNow.AddSeconds(5));
Console.WriteLine($"Grpc.Net.Client: {reply.ReservationId[..10]}...");
Grpc.Core: ord-legacy...
Grpc.Net.Client: ord-legacy...

The generated client class (InventoryServiceClient) is the same in both implementations; Grpc.Tools generates the same code from the same .proto. Only the channel changes. That's why migration is small in most projects: GrpcChannel.ForAddress instead of Channel, a scheme in the address (http:// or https://), Dispose instead of ShutdownAsync. The differences to watch are in channel options: Grpc.Core's list of string-keyed ChannelOptions became typed properties on GrpcChannelOptions in grpc-dotnet (message size limits, HttpHandler, credentials). On the server side, you need to move your own host written with Grpc.Core.Server to ASP.NET Core; that's the AddGrpc/MapGrpcService setup from the series' first gRPC service post.

What grpc-dotnet brought release by release, together with where we used it in the series:

ReleaseWhat arrivedWhere in the series
.NET Core 3.0 (2019)Grpc.AspNetCore and Grpc.Net.Client; gRPC on KestrelFirst gRPC service and client
Grpc.AspNetCore.Web 2.29 (May 2020)First stable package for gRPC-WebgRPC from browsers
Grpc.Net.Client 2.36 (March 2021)Retries and hedging through service configgRPC in production
Grpc.Net.Client 2.45 (April 2022), .NET 5+Client-side load balancing: dns:/// resolver, round_robingRPC in production
.NET 7 (November 2022)JSON (JavaScript Object Notation) transcoding (Microsoft.AspNetCore.Grpc.JsonTranscoding)gRPC from browsers
.NET 8Native AOT support for gRPC clients and servers
Grpc.Net.ClientFactory 2.64 (July 2024)Compatibility with AddStandardResilienceHandlergRPC in production
.NET 11 (November 2026)Microsoft.AspNetCore.Grpc.Swagger deprecatedgRPC from browsers

The quickest way to see the practical effect of the last row in an old project is to ask NuGet for deprecation information:

dotnet list package --deprecated

Project `LegacyGrpc` has the following deprecated packages
   [net10.0]:
   Top-level Package                        Requested   Resolved   Reason(s)   Alternative
   > Microsoft.AspNetCore.Grpc.Swagger      0.10.11     0.10.11    Other                  

The deprecation message on NuGet says the package is no longer maintained and doesn't suggest an alternative. Grpc.Core didn't show up in this list: being in maintenance mode doesn't mean it's marked as deprecated on NuGet. So the command's output alone isn't a sufficient checklist.

Hot Chocolate: From 13 to 16

Hot Chocolate's last four major releases are dated 7 February 2023 (13), 16 October 2024 (14), 4 February 2025 (15) and 30 April 2026 (16) on NuGet. Everything we used in this series has a history:

  • 13: GraphQL over HTTP's media type (application/graphql-response+json) became the default, request batching was turned off by default, @defer/@stream moved behind EnableDefer/EnableStream, subscription topics became strings and AddInMemorySubscriptions moved to the schema builder.
  • 14: the complexity analyzer gave way to cost analysis based on the draft of IBM's Cost specification, on by default; services became injectable without [Service], query resolvers switched to the resolver scope by default, RegisterDbContext gave way to RegisterDbContextFactory, Banana Cake Pop was renamed Nitro, and "persisted query" became "persisted operation".
  • 15: support for .NET Standard 2.0, .NET 6 and .NET 7 was dropped; source-generated DataLoaders became the recommendation over registering DataLoaders by hand; DateOnly and TimeOnly were bound to their own scalars.
  • 16: the new scalar API, [BatchResolver], onError, incremental delivery v0.2, semantic introspection (__search), [RequiresOptIn], OpenTelemetry spans following the GraphQL semantic conventions, MCP and OpenAPI adapters; the schema built at startup, and schema services separated from application services.

We ran into most of 16's items in the series' earlier posts: [BatchResolver] hanging on a query with two root fields, the error filter needing AddApplicationService, @defer's new wire format. These are also the longest sections of the migration guide (15 to 16); there are also items that silently change behavior, such as the limit of 64 operations per filter argument, the fieldCoordinate error extension becoming coordinate, and OpenTelemetry span names changing. In a system whose dashboards and alert rules depend on span names, that last item is as much an operations task as a migration task.

The Scalar API: From Parse/Serialize to Coerce

Custom scalars are where an old Hot Chocolate project breaks the most. For Bookstore I wrote a scalar for the ISBN: it strips the dashes and expects 13 digits starting with 978 or 979. With Hot Chocolate 15:

// Hot Chocolate 15 and earlier: the Parse*/Serialize family
public sealed class IsbnType() : ScalarType<string, StringValueNode>("Isbn")
{
    protected override bool IsInstanceOfType(StringValueNode valueSyntax) =>
        Isbn.Normalize(valueSyntax.Value) is not null;

    protected override string ParseLiteral(StringValueNode valueSyntax) =>
        Isbn.Normalize(valueSyntax.Value) ?? throw new SerializationException("Invalid ISBN.", this);

    protected override StringValueNode ParseValue(string runtimeValue) => new(runtimeValue);

    public override IValueNode ParseResult(object? resultValue) =>
        resultValue is string s ? new StringValueNode(s) : NullValueNode.Default;

    public override bool TryDeserialize(object? resultValue, out object? runtimeValue)
    {
        runtimeValue = resultValue is string s ? Isbn.Normalize(s) : null;
        return runtimeValue is not null;
    }
}

The same class didn't compile against Hot Chocolate 16.7.5. All five overrides failed with CS0115 ("no suitable method found to override"); the old methods aren't on the base class. On top of that came CS0534 for four new abstract members: OnCoerceInputLiteral(StringValueNode), OnCoerceInputValue(JsonElement, IFeatureProvider), OnCoerceOutputValue(string, ResultElement) and OnValueToLiteral(string). The new version:

// Hot Chocolate 16: four coerce/literal methods
public sealed class IsbnType() : ScalarType<string, StringValueNode>("Isbn")
{
    protected override string OnCoerceInputLiteral(StringValueNode valueLiteral) =>
        Isbn.Normalize(valueLiteral.Value) ?? throw CreateCoerceInputLiteralError(valueLiteral);

    protected override string OnCoerceInputValue(JsonElement inputValue, IFeatureProvider context) =>
        Isbn.Normalize(inputValue.GetString()) ?? throw CreateCoerceInputValueError(inputValue);

    protected override void OnCoerceOutputValue(string runtimeValue, ResultElement resultValue) =>
        resultValue.SetStringValue(runtimeValue);

    protected override StringValueNode OnValueToLiteral(string runtimeValue) => new(runtimeValue);
}

The names follow the specification's language: you resolve a literal in the query text (OnCoerceInputLiteral) and a JSON variable (OnCoerceInputValue) separately, and you produce output not by returning an object but by writing straight into the response's JSON structure (ResultElement). Variables now arrive as System.Text.Json's JsonElement; there's no intermediate dictionary or object tree. The same queries on both versions:

{ bookByIsbn(isbn: "978-0000000001") { isbn title } }
15: {"data":{"bookByIsbn":{"isbn":"9780000000001","title":"Distributed Systems Vol. 1"}}}
16: {"data":{"bookByIsbn":{"isbn":"9780000000001","title":"Distributed Systems Vol. 1"}}}

{ bookByIsbn(isbn: "12345") { title } }
15: {"errors":[{"message":"The specified argument value does not match the argument type.","locations":[{"line":1,"column":20}],"path":["bookByIsbn"],"extensions":{"argument":"isbn","argumentValue":"\"12345\"","locationType":"Isbn!","specifiedBy":"https://spec.graphql.org/October2021/#sec-Values-of-Correct-Type"}}]}
16: {"errors":[{"message":"Isbn cannot coerce the given literal of type `StringValue` to a runtime value.","path":["bookByIsbn"],"extensions":{"inputPath":["isbn"],"coordinate":"Query.bookByIsbn(isbn:)","fieldType":"Isbn"}}],"data":{"bookByIsbn":null}}

query($i: Isbn!) { bookByIsbn(isbn: $i) { title } }   variables: {"i":"978-x"}
15: {"errors":[{"message":"Isbn cannot parse the given literal of type `StringValueNode`.","path":["i"]}]}
16: {"errors":[{"message":"Isbn cannot coerce the given value JSON element of type `String` to a runtime value.","extensions":{"inputPath":["i"],"fieldType":"Isbn"}}]}

There's no difference for a valid value. For an invalid literal the behavior changed: in 15 it was rejected at validation thanks to IsInstanceOfType, and no data came back at all; in 16 the error occurred during coercion, at field level, and came back together with bookByIsbn: null inside data. The error message and the extensions changed too (inputPath, coordinate). Don't migrate scalars without testing in a system where clients match error messages as text. Also note that the specifiedBy address in 15 points to the October 2021 specification, while the 16 output in the series' earlier posts pointed to September 2025.

The GraphQL Specification: From October 2021 to September 2025

The September 2025 edition of the specification is the first official release after October 2021. The additions we used in the series' GraphQL fundamentals post: @oneOf input objects, @deprecated on arguments and input fields, descriptions on operations and fragments, schema coordinates and full Unicode support. What stayed out matters at least as much: @defer and @stream are still not in the specification and their wire formats (from v0.1 to v0.2) keep changing in the working group; GraphQL over HTTP is still a draft; the work on composing the schemas of several services (Composite Schemas) continues as a separate specification effort; onError and @requiresOptIn are at the proposal stage. Hot Chocolate 16 implements all of these; that's why "Hot Chocolate supports it" and "it's in the specification" are not the same thing. Make that distinction before relying on a feature on the client side.

Reading an Old Project

The places I'd look at in the first half hour of an inherited gRPC or GraphQL project:

  • Packages: dotnet list package --deprecated and --outdated; is Grpc.Core among the references (seeing only Grpc.Core.Api is fine, that's the managed API package grpc-dotnet uses too); are all HotChocolate.* packages on the same version.
  • gRPC clients: if you see new Channel(...) and ChannelOption, it's Grpc.Core; a new channel per call and calls without deadlines are the pitfalls from the series' first gRPC post.
  • Hot Chocolate 12–13 code: the [Service] and [ScopedService] attributes, RegisterDbContext<T>(DbContextKind.Pooled), [SubscribeAndResolve], IQueryResult; the equivalents of all of these changed in 14 and later.
  • Scalars: overrides of ParseLiteral, ParseValue, Serialize, TryDeserialize mean code to rewrite when moving to 16.
  • Library: if you see using GraphQL; and the GraphQL.NewtonsoftJson package, the project uses GraphQL.NET, not Hot Chocolate; that package is GraphQL.NET's Newtonsoft.Json-based serializer. The APIs are completely different; the code in this series doesn't carry over directly.
  • Security defaults: cost analysis became the default with 14; in an older version, check explicitly which analyses are on, and the introspection and error detail settings.

Production Notes

Don't do major version upgrades in one step. In Hot Chocolate, instead of jumping from 13 to 16, apply each major release's migration guide in order; at each step export the schema and diff it against the previous step. The schema diff from the series' safe-operations post helps here too: if the migration changed something unintentionally, the diff shows it.

Treat observability as part of the migration. Span names, metric names and error codes can change; verify dashboards and alert rules in the same environment as the new version.

Put maintenance-mode dependencies in your inventory. A package like Grpc.Core may work today; but a dependency that gets no security fixes and whose native library isn't built for new platforms turns into a risk item over time.

Common Pitfalls

Saying "it compiles, so there's no problem". Grpc.Core compiles on .NET 10; the fact that it's in maintenance mode isn't reflected in the compiler.

Treating NuGet's deprecation list as the only measure. A package in maintenance mode may not be marked as deprecated.

Validating a scalar migration by compiling alone. Error messages, the stage at which the error occurs and whether data is present can all change; test together with the clients.

Mistaking a Hot Chocolate feature for the GraphQL specification. @defer, onError and semantic introspection may be missing or work differently on another server.

A Small Experiment

★☆☆ Run dotnet list package --deprecated and --outdated on one of your own projects; for every package that shows up, check its maintenance status in the package's repository.

★★☆ Add an error with a more descriptive message for invalid ISBNs to the 16 version of this post's IsbnType (by overriding CreateCoerceInputLiteralError) and verify the message on both the literal and the variable paths.

★★★ Move a small server that uses Grpc.Core (Grpc.Core.Server) to ASP.NET Core: same .proto, same service implementation; only the host changes. Show with a test client that deadline, metadata and status code behavior stays the same in both.

This is where the series ends. Starting from Protocol Buffers contracts, we took gRPC to production and GraphQL with Hot Chocolate 16 all the way to Angular, and measured the three API styles on the same scenario. We'll see how Bookstore's services talk to each other end to end with gRPC, inside a real distributed system, in the upcoming .NET Microservices Application series.