GraphQL·C#·Free

GraphQL to
C#.

Turn a GraphQL SDL schema into a modern C# client. Records with required and init, nullable reference types straight from the schema's !, a [JsonPolymorphic] interface for every union. Typed query, mutation, and subscription clients over HttpClient — subscriptions surface as IAsyncEnumerable<T> over a graphql-transport-ws socket. No signup, no install.

SDL·C# 12·HttpClient·System.Text.Json·MIT
schema.graphqlInput
scalar DateTime

enum Currency {
  EUR
  USD
}

enum PaymentStatus {
  PENDING
  SETTLED
  FAILED
}

type CardPayment {
  last4: String!
  brand: String!
}

type BankPayment {
  iban: String!
}

union PaymentMethod = CardPayment | BankPayment

type Payment {
  id: ID!
  customerId: String!
  amount: Int!
  currency: Currency!
  status: PaymentStatus!
  description: String
  method: PaymentMethod
  createdAt: DateTime!
}
compile
Payment.cs tsc cleanOutput
// Models/Payment.cs
using System;
using System.Text.Json.Serialization;

namespace Example.Payments.Models;

public record Payment
{
    [JsonPropertyName("id")]
    public required string Id { get; init; }
    [JsonPropertyName("customerId")]
    public required string CustomerId { get; init; }
    [JsonPropertyName("amount")]
    public required int Amount { get; init; }
    [JsonPropertyName("currency")]
    public required Currency Currency { get; init; }
    [JsonPropertyName("status")]
    public required PaymentStatus Status { get; init; }
    [JsonPropertyName("description")]
    public string? Description { get; init; }
    [JsonPropertyName("method")]
    public PaymentMethod? Method { get; init; }
    [JsonPropertyName("createdAt")]
    public required DateTime CreatedAt { get; init; }
}
What gets generated

Idiomatic C#. Not a generated-sources folder.

Below: types emitted from a small payments schema with a union, a nullable field, and an enum. Every type is a record with init properties; required covers every non-null SDL field; the union becomes a [JsonPolymorphic] interface keyed on __typename, so a plain switch matches it.

// Models/Payment.cs
public record Payment
{
    [JsonPropertyName("id")]
    public required string Id { get; init; }
    [JsonPropertyName("description")]
    public string? Description { get; init; }
    [JsonPropertyName("method")]
    public PaymentMethod? Method { get; init; }
    [JsonPropertyName("createdAt")]
    public required DateTime CreatedAt { get; init; }
    // ...
}

// Models/Currency.cs
[JsonConverter(typeof(JsonStringEnumConverter))]
public enum Currency
{
    EUR,
    USD
}

// Models/PaymentMethod.cs
[JsonPolymorphic(TypeDiscriminatorPropertyName = "__typename")]
[JsonDerivedType(typeof(CardPayment), "CardPayment")]
[JsonDerivedType(typeof(BankPayment), "BankPayment")]
public interface PaymentMethod { }

// Match the union with a plain switch — System.Text.Json
// reads __typename and hands you the concrete record:
string Label(Payment p) => p.Method switch
{
    CardPayment c => $"card {c.Last4}",
    BankPayment b => $"bank {b.Iban}",
    null          => "no method",
    _             => throw new InvalidOperationException(),
};
Three things we get right

Grounded in how the schema reads.

Each claim is something you can verify by running the converter on your own .graphql schema. Click a card to see the SDL fragment that triggered it and the C# that came out.

01ProofRecords with required and init — nullability comes from the schema.Spec · InCode · Out
schema.graphql fragment
type Payment {
  id: ID!
  description: String
  method: PaymentMethod
  createdAt: DateTime!
  # ...
}
Payment.cs
public record Payment
{
    [JsonPropertyName("id")]
    public required string Id { get; init; }
    [JsonPropertyName("description")]
    public string? Description { get; init; }
    [JsonPropertyName("method")]
    public PaymentMethod? Method { get; init; }
    [JsonPropertyName("createdAt")]
    public required DateTime CreatedAt { get; init; }
    // ...
}

// - record, not a class with setters
// - 'String!' -> required string; 'String' -> string?
// - a nullable union field becomes PaymentMethod?
// - DateTime via --custom-scalar DateTime=System.DateTime
// - [JsonPropertyName] pins the camelCase wire name
C# · HttpClient

Typed clients, not a raw transport.

Operations split into QueryClient, MutationClient, and SubscriptionClient, all extending a thin BaseHttpClient that owns the HttpClient and base URL. Queries and mutations POST a GraphQL document — with the full selection set generated inline — to /graphql; ExecuteGraphQLAsync reads data.<field> and surfaces an errors[] array as an HttpRequestException. Subscriptions return IAsyncEnumerable<T> over a graphql-transport-ws WebSocket that the base client derives from the base URL. System.Text.Json handles both sides.

Client/QueryClient.cs generated
// Client/QueryClient.cs
using System.Net.Http;
using System.Text.Json;
using Example.Payments.Models;

namespace Example.Payments.Client;

public class QueryClient : BaseHttpClient
{
    public QueryClient(HttpClient httpClient, string baseUrl, JsonSerializerOptions? jsonOptions)
        : base(httpClient, baseUrl, jsonOptions) { }

    public async Task<Example.Payments.Models.Payment?> PaymentAsync(
        string id, CancellationToken cancellationToken = default)
    {
        var variables = new Dictionary<string, object?> { { "id", id } };
        return await ExecuteGraphQLAsync<Example.Payments.Models.Payment>(
            "query Payment($id: ID!) { payment(id: $id) { ... } }",  // full selection set generated inline
            "Payment", variables, "payment", cancellationToken).ConfigureAwait(false);
    }
}

// Client/SubscriptionClient.cs
public class SubscriptionClient : BaseHttpClient
{
    public async IAsyncEnumerable<Example.Payments.Models.Payment> PaymentSettledAsync(
        [EnumeratorCancellation] CancellationToken cancellationToken = default)
    {
        await foreach (var item in SubscribeAsync<Example.Payments.Models.Payment>(
            "subscription PaymentSettled { paymentSettled { ... } }",
            "PaymentSettled", null, "paymentSettled", cancellationToken).ConfigureAwait(false))
        {
            yield return item;
        }
    }
}

// Client/BaseHttpClient.cs (shared)
public abstract class BaseHttpClient
{
    protected readonly HttpClient _httpClient;
    protected readonly string _baseUrl;
    protected readonly JsonSerializerOptions? _jsonOptions;
    protected readonly GraphQLWebSocketConnection _ws;

    public BaseHttpClient(HttpClient httpClient, string baseUrl, JsonSerializerOptions? jsonOptions = null)
    {
        _httpClient = httpClient;
        _baseUrl = baseUrl.TrimEnd('/');
        _jsonOptions = jsonOptions;
        // http -> ws, https -> wss; subscriptions hit <baseUrl>/graphql
        var wsBase = _baseUrl.StartsWith("https://", StringComparison.OrdinalIgnoreCase)
            ? "wss://" + _baseUrl.Substring("https://".Length)
            : "ws://" + _baseUrl.Substring("http://".Length);
        _ws = new GraphQLWebSocketConnection(new Uri(wsBase + "/graphql"), _jsonOptions);
    }

    protected async Task<T?> ExecuteGraphQLAsync<T>(
        string query, string operationName,
        Dictionary<string, object?>? variables, string fieldName,
        CancellationToken cancellationToken)
    {
        var graphqlBody = new Dictionary<string, object?>
        {
            { "query", query },
            { "operationName", operationName },
        };
        if (variables != null) graphqlBody["variables"] = variables;
        using var request = new HttpRequestMessage(HttpMethod.Post, $"{_baseUrl}/graphql");
        request.Content = new StringContent(
            JsonSerializer.Serialize(graphqlBody, _jsonOptions), Encoding.UTF8, "application/json");
        using var response = await _httpClient.SendAsync(request, cancellationToken).ConfigureAwait(false);
        response.EnsureSuccessStatusCode();
        // Reads data.<fieldName>; an errors[] array surfaces as HttpRequestException.
        return await ReadGraphQLFieldAsync<T>(response, fieldName, cancellationToken).ConfigureAwait(false);
    }

    // Subscriptions: one ClientWebSocket (graphql-transport-ws) multiplexes every
    // active subscription; each yields its 'next' frames as IAsyncEnumerable<T>.
    protected async IAsyncEnumerable<T> SubscribeAsync<T>(
        string query, string operationName,
        Dictionary<string, object?>? variables, string fieldName,
        [EnumeratorCancellation] CancellationToken cancellationToken)
    { /* connection_init -> subscribe -> read next/error/complete */ }
}
Also available via MCP

Or ask Claude to do it.

The same generator ships as a Model Context Protocol server. It calls the same web API this converter uses, so the output is identical. Point Claude Desktop, Cursor, or any MCP-aware agent at a .graphql schema and it produces the C# client as a diff — free, no token, no leaving the chat.

// claude-desktop config · .mcp.json
{
  "mcpServers": {
    "metaengine": {
      "command": "npx",
      "args": ["-y", "@metaengine/mcp-server"]
    }
  }
}

// Then in Claude:
// "Load schema.graphql and generate a C# HttpClient
//  in namespace Acme.Payments."
See the MCP page
Questions

Things people ask.

The questions we keep getting about GraphQL → C#. If yours is not here, run the converter — the answer is usually in the output.

A namespace of .cs files: one record per object and input type, one enum per enum, a [JsonPolymorphic] interface plus member records per union, and a typed client per operation root — QueryClient, MutationClient, SubscriptionClient. Plus a shared BaseHttpClient with the GraphQL-over-HTTP POST helper and the subscription WebSocket. System.Text.Json only — no extra runtime package.

Try it on your own schema.

The converter runs in the browser. Your schema never leaves the page.

Open the converter