GraphQL Client
Trax.Api.GraphQL.Client is the outbound GraphQL story for .NET. It is the inverse of Strawberry Shake: queries are written by hand (raw string, or .graphql resource file), and a runtime validator confirms they match the server's schema at startup. No codegen, no .graphql.cs files in your repo, no IntelliSense lying to you about a schema you haven't talked to yet.
The package sits next to Trax.Api.GraphQL (the server). The kernel is fully usable outside Trax: any .NET app that wants to call a GraphQL endpoint can install it without dragging in trains, mediators, or a database. In-ecosystem consumers add the .Trax integration package (see Trax integration) for junction support, log correlation, and AssemblySchemaProvider.
Quick Setup
dotnet add package Trax.Api.GraphQL.Clientservices.AddTraxGraphQLClient(new Uri("https://api.example.com/graphql"));
// Or, chain configuration:
services
.AddTraxGraphQLClient(new Uri("https://api.example.com/graphql"))
.WithStrictness(ResponseStrictness.ThrowOnDrift)
.UseFileSchema("schema.graphql");
// Optional: validate the request types in your assembly against the schema.
await app.Services.ValidateGraphQLClientAssembliesAsync(typeof(Program).Assembly);AddTraxGraphQLClient returns a TraxGraphQLClientBuilder. Chain Use*Schema() /
WithStrictness() / ConfigureHttpClient() / UseStartupValidation() to layer features.
If you don't chain anything, you get the kernel defaults (introspection + lenient).
Query Modes
Three coexisting modes, all running through the same IGraphQLClientExecutor. Pick per request, not per app.
Mode A: Raw string
public sealed class GetPlayerRequest : IGraphQLClientRequest<PlayerProfile>
{
public required string Id { get; init; }
public string Query => """
query GetPlayer($id: String!) {
player(id: $id) { id name level }
}
""";
public object Variables => new { id = Id };
}Use for one-off queries, anything with fragments / unions, queries copy-pasted from a Playground session.
A request's document holds exactly one operation. Its fragments can come before or after it, since definition order means nothing in GraphQL. A document with two operations is refused when it is validated: the client sends no operation name, so the server could not tell which one to run.
Mode E: .graphql resource file
[GraphQLQueryResource("GetPlayer.graphql")]
public sealed class GetPlayerRequest : GraphQLResourceRequest<PlayerProfile>
{
public required string Id { get; init; }
public override object Variables => new { id = Id };
}Place GetPlayer.graphql next to the C# file. In the consuming csproj:
<ItemGroup>
<EmbeddedResource Include="**/*.graphql" />
</ItemGroup>You get real GraphQL syntax highlighting from any IDE, the C# file shrinks to a POCO plus an attribute, and the loader caches the resource once per type.
Mode D: POCO-derived queries
[GraphQLType("Player")]
public sealed record TypedPlayer(string Id, string Name, int? Level, string Rank);
[GraphQLOperation(OperationType.Query, RootField = "player")]
public sealed class GetPlayerRequest : TypedRequest<TypedPlayer>
{
[GraphQLArgument("String!", VariableName = "id")]
public required string Id { get; init; }
}The POCO declares the shape; TypedQueryGenerator walks the properties at startup and emits the query. Refactor a property, the query updates with it. Ships in Trax.Api.GraphQL.Client.Typed.
How a property becomes a selection:
| On the property | Selected as |
|---|---|
| nothing | the camel-cased property name: Level selects level |
[JsonPropertyName("rank")] | that name |
[GraphQLField("name")] | the schema field, aliased to the property's response key when they differ: DisplayName selects displayName: name, so the response deserializes without a matching [JsonPropertyName] |
[JsonIgnore] (Condition = Always) | nothing: the property is never read |
[JsonIgnore(Condition = WhenWritingNull)], WhenWritingDefault, Never | the property as usual: those conditions affect writing only, and the response still fills it |
A property whose type is another POCO gets its own sub-selection. A result type that leads back
to itself, directly (Category.Parent is a Category) or through other types, is refused with
an InvalidOperationException naming the loop, because a GraphQL selection must be finite. Give
the nested level its own type that stops where the query should, or mark the property
[JsonIgnore]. The same type under two sibling properties (Home and Away) is fine.
[GraphQLOperation] property | Purpose |
|---|---|
OperationType (positional) | Query or Mutation. |
Name | Override the operation name. Defaults to the request type name with a trailing Request stripped. |
RootField | Override the schema field selected at the top of the operation. Defaults to the camel-cased operation name. |
Path | Dot-separated chain of wrapper field names above RootField. Set when the schema groups fields under a multi-level envelope. |
Nested envelopes (Path)
Trax servers expose namespaced trains under query { discover { {namespace} { {field} } } }. To query through that envelope from a typed request, set Path:
[GraphQLOperation(OperationType.Query, Path = "discover.netsuite", RootField = "typedCustomer")]
public sealed class FindCustomerByEmailRequest : TypedRequest<TypedCustomer?>
{
[GraphQLArgument("String!", VariableName = "email")]
public required string Email { get; init; }
}Generates:
query FindCustomerByEmail($email: String!) {
discover {
netsuite {
typedCustomer(email: $email) {
id
email
...
}
}
}
}The default extractor walks the same path before deserializing, so consumers stay in typed mode for nested schemas instead of falling back to raw-string with a custom Extract. Path values must be dot-separated field names with no whitespace; empty strings, leading/trailing dots, and doubled dots throw at startup.
Schema Providers
| Provider | When to use |
|---|---|
IntrospectingSchemaProvider (default) | The live endpoint is reachable and lets this client introspect. Cheapest setup, weakest guarantee (drift between intro and check). A Trax server allows introspection only in Development unless its host passes AllowIntrospection a predicate that admits this client, so against a production Trax server pick one of the other two. |
FileSchemaProvider | CI, air-gapped builds, or you want startup validation against a checked-in SDL snapshot. Use a periodic introspection job to keep the snapshot fresh and alert on drift separately. |
AssemblySchemaProvider | In-ecosystem only. Builds the server's ISchema in-process from its DLL. Strongest query-string guarantee with zero network and zero file drift. Ships in Trax.Api.GraphQL.Client.Trax. |
Every provider loads the schema on first use and shares it after that. A load that fails (the server was down, the file was missing) is not kept: the next validation loads again, so one bad moment at boot does not leave the client unable to validate until the process restarts. A caller's cancellation token stops that caller waiting; a load other callers are waiting on carries on.
// Pick a schema provider on the builder. Default is introspection; calling Use*Schema
// swaps in the provider you want.
services.AddTraxGraphQLClient(uri).UseFileSchema("schema.graphql");
services.AddTraxGraphQLClient(uri).UseAssemblySchema(PlayerSchema.Configure); // .Trax packageTalking to multiple servers
One AddTraxGraphQLClient call registers a single client. To call two servers with different
schemas from the same container, register each under a key with AddKeyedTraxGraphQLClient and
resolve by key:
services.AddKeyedTraxGraphQLClient("serverB", new Uri("https://b.example.com/graphql"))
.UseFileSchema("b.graphql");
services.AddKeyedTraxGraphQLClient("serverC", new Uri("https://c.example.com/graphql"))
.UseFileSchema("c.graphql");public class CrossServerTrain(
[FromKeyedServices("serverB")] IGraphQLClientExecutor b,
[FromKeyedServices("serverC")] IGraphQLClientExecutor c)
{
// b talks to server B, c talks to server C.
}The key names the downstream server. It is arbitrary (the library never inspects it) and
independent of the server-side Namespace that shows up in a request's Path. Each key gets
its own configuration, HttpClient, schema provider, and validator cache, so a request meant
for one server fails schema validation if you run it through the other server's key.
Without keying, a second AddTraxGraphQLClient call overrides the first (last registration
wins) and both clients validate against a single schema. Keying keeps them isolated. Every
builder method works on a keyed registration (UseFileSchema, UseAssemblySchema,
WithStrictness, ConfigureHttpClient, UseStartupValidation).
To validate up front, mark each request with the key of the client it belongs to:
[GraphQLClient("serverB")]
public sealed class GetInvoiceRequest : IGraphQLClientRequest<Invoice> { /* ... */ }
[GraphQLClient("serverC")]
public sealed class GetShipmentRequest : IGraphQLClientRequest<Shipment> { /* ... */ }Validation for a keyed client checks only the requests marked with its key, and validation for the unkeyed client checks only the requests with no mark, so requests for every server can share one assembly. The key is compared by value, so an enum key works as well as a string. The mark only steers validation: which server a request goes to is still decided by the executor you resolve.
services.AddKeyedTraxGraphQLClient("serverB", serverBUri).UseStartupValidation(typeof(Program).Assembly);
services.AddKeyedTraxGraphQLClient("serverC", serverCUri).UseStartupValidation(typeof(Program).Assembly);
// Or without the .Trax package:
await app.Services.ValidateGraphQLClientAssembliesAsync("serverB", typeof(Program).Assembly);A validation that finds no request for its client refuses to start, naming the key. That is a request someone forgot to mark, and passing after checking nothing would hide it. A request marked with a key no client is registered under is validated by nobody, so check the spelling of the key.
The single-server AddTraxGraphQLClient is unchanged. Use it when you talk to one server.
Response Strictness
Strict-extract catches "I added a field to the query but forgot to add it to the POCO" on the first response, not the hundredth bug report. Validation runs once per request type (cached) and only when the request uses the default Extract.
services.AddTraxGraphQLClient(uri).WithStrictness(ResponseStrictness.ThrowOnDrift);| Setting | Behavior |
|---|---|
Lenient (default) | Extra JSON fields silently ignored. Matches System.Text.Json's default. |
WarnOnDrift | Drift logged at warning level via ILogger<GraphQLClientExecutor>. Call still succeeds. Best for production. |
ThrowOnDrift | Drift throws GraphQLResponseShapeException. Best for dev and integration tests. |
Trax Integration
The Trax.Api.GraphQL.Client.Trax package contributes additional methods on the
TraxGraphQLClientBuilder returned by AddTraxGraphQLClient:
services
.AddTraxGraphQLClient(playerServiceUri)
.UseAssemblySchema(PlayerSchema.Configure) // strongest schema source
.UseStartupValidation(typeof(Program).Assembly); // boot fails on driftAvailable methods (extension methods on TraxGraphQLClientBuilder):
| Method | What it does |
|---|---|
.UseAssemblySchema(configureDelegate) | Builds the server's HotChocolate schema in-process from the same delegate the server uses. Zero network, zero file drift. |
.UseStartupValidation(assemblies) | Registers a hosted service that validates this client's request types at boot: those marked [GraphQLClient(key)] with its key, or the unmarked ones for the unkeyed client. Schema drift becomes a startup failure, not a runtime 400, and finding no request for the client fails too. |
External consumers install only Trax.Api.GraphQL.Client and never see these methods.
SDK Reference
AddTraxGraphQLClient | AddKeyedTraxGraphQLClient | TraxGraphQLClientBuilder | ValidateGraphQLClientAssembliesAsync | IGraphQLClientRequest | GraphQLResourceRequest | ResponseStrictness