GraphQL·Kotlin·Free

GraphQL to
Kotlin Ktor.

Turn a GraphQL SDL schema into a Kotlin client built on Ktor and kotlinx.serialization. Every object type becomes a @Serializable data class; every union becomes a sealed interface tagged with @JsonClassDiscriminator("__typename") so polymorphic JSON decodes straight into the right variant. Every query and mutation is a suspend fun on an HttpClient you own; every subscription is a Flow<T> over graphql-transport-ws. No signup, no install.

GraphQL SDL·Kotlin·Ktor 3.x·kotlinx.serialization·MIT
schema.graphqlInput
type Query {
  payment(id: ID!): Payment
  payments(limit: Int): [Payment!]!
}

type Mutation {
  createPayment(input: CreatePaymentInput!): Payment!
}

type Subscription {
  paymentUpdated(id: ID!): Payment!
}

enum PaymentStatus {
  PENDING
  SETTLED
  FAILED
}

union PaymentMethod = CardPayment | BankPayment

type Payment {
  id: ID!
  amount: Float!
  currency: String
  status: PaymentStatus!
  method: PaymentMethod!
}
compile
com/example/payments/client/QueryClient.kt tsc cleanOutput
// com/example/payments/model/Payment.kt
package com.example.payments.model

import kotlinx.serialization.Serializable

@Serializable
data class Payment(
    val id: String,
    val amount: Double,
    val currency: String? = null,
    val status: PaymentStatus,
    val method: PaymentMethod
)

// com/example/payments/client/QueryClient.kt
class QueryClient(httpClient: HttpClient, baseUrl: String) : BaseHttpClient(httpClient, baseUrl) {

    private object GraphQLQueries {
        const val Payment = """query Payment($id: ID!) {
          payment(id: $id) {
            id amount currency status
            method {
              __typename
              ... on CardPayment { kind last4 brand }
              ... on BankPayment { kind iban }
            }
          }
        }"""
    }

    suspend fun payment(id: String): Payment? {
        val variables = buildJsonObject { put("id", Json.encodeToJsonElement(id)) }
        return executeGraphQLNullable(
            GraphQLQueries.Payment, "Payment", variables, "payment")
    }
}
What gets generated

Data classes, a sealed interface, suspend + Flow.

Below: types and clients emitted from a small payments schema with an enum, a union, an input, and a subscription. Each object type lands in its own .kt file as a @Serializable data class; the union becomes a sealed interface tagged with @JsonClassDiscriminator("__typename") so kotlinx.serialization routes the payload to the right variant; queries and mutations are suspend funs; the subscription is a Flow<Payment>.

// com/example/payments/model/Payment.kt
package com.example.payments.model

import kotlinx.serialization.Serializable

@Serializable
data class Payment(
    val id: String,
    val amount: Double,
    val currency: String? = null,
    val status: PaymentStatus,
    val method: PaymentMethod
)

// com/example/payments/model/PaymentMethod.kt
package com.example.payments.model

import kotlinx.serialization.Serializable
import kotlinx.serialization.json.JsonClassDiscriminator

@Serializable
@JsonClassDiscriminator("__typename")
sealed interface PaymentMethod

// com/example/payments/model/CardPayment.kt
@Serializable
@SerialName("CardPayment")
data class CardPayment(val kind: String, val last4: String, val brand: String) : PaymentMethod

// com/example/payments/model/PaymentStatus.kt
@Serializable
enum class PaymentStatus { PENDING, SETTLED, FAILED }

// com/example/payments/client/QueryClient.kt
suspend fun payment(id: String): Payment? { /* ... */ }

// com/example/payments/client/SubscriptionClient.kt
fun paymentUpdated(id: String): Flow<Payment> {
    val variables = buildJsonObject { put("id", Json.encodeToJsonElement(id)) }
    return subscribe(
        GraphQLQueries.PaymentUpdated, "PaymentUpdated", variables, "paymentUpdated")
}
Three things we get right

Grounded in how the schema reads.

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

01ProofUnions become sealed interfaces.Spec · InCode · Out
schema.graphql fragment
union PaymentMethod = CardPayment | BankPayment

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

type BankPayment {
  kind: String!
  iban: String!
}
emitted + call-site
// model/PaymentMethod.kt
@Serializable
@JsonClassDiscriminator("__typename")
sealed interface PaymentMethod

// model/CardPayment.kt
@Serializable
@SerialName("CardPayment")
data class CardPayment(
    val kind: String,
    val last4: String,
    val brand: String,
) : PaymentMethod

// model/BankPayment.kt
@Serializable
@SerialName("BankPayment")
data class BankPayment(val kind: String, val iban: String) : PaymentMethod

// The emitted operation keeps __typename on the
// union selection, so the JSON payload carries
// the discriminator kotlinx.serialization needs:
//
//   method {
//     __typename
//     ... on CardPayment { kind last4 brand }
//     ... on BankPayment { kind iban }
//   }

// Pattern-match at the call site:
val label = when (val m = payment.method) {
    is CardPayment -> "card ${m.last4}"
    is BankPayment -> "bank ${m.iban}"
}
// Add a third union member in the schema and
// this 'when' stops compiling until handled.
Kotlin · Ktor 3.x

One HttpClient. Every generated client.

You own the HttpClient. Pick the engine (CIO on JVM, OkHttp on Android, Darwin on iOS, JS in browsers), install ContentNegotiation with Json(), WebSockets for subscriptions, Auth, Logging, and HttpTimeout once, and share it across QueryClient, MutationClient, and SubscriptionClient. Nothing in the generated output reaches into that layer, and nothing has to change when you swap engines or add a plugin.

com/example/payments/HttpClientFactory.kt generated
// com/example/payments/HttpClientFactory.kt
package com.example.payments

import io.ktor.client.*
import io.ktor.client.engine.cio.*
import io.ktor.client.plugins.HttpTimeout
import io.ktor.client.plugins.auth.*
import io.ktor.client.plugins.auth.providers.*
import io.ktor.client.plugins.contentnegotiation.*
import io.ktor.client.plugins.logging.*
import io.ktor.client.plugins.websocket.*
import io.ktor.serialization.kotlinx.json.*

// One HttpClient. Share it across queries, mutations,
// and the subscription transport.
val httpClient = HttpClient(CIO) {
    install(ContentNegotiation) {
        json()  // kotlinx.serialization — decodes your models
    }
    install(WebSockets)  // graphql-transport-ws lives here
    install(Auth) {
        bearer {
            loadTokens { BearerTokens(accessToken, refreshToken) }
            refreshTokens { /* refresh flow */ }
        }
    }
    install(Logging) { level = LogLevel.INFO }
    install(HttpTimeout) {
        requestTimeoutMillis = 10_000
        connectTimeoutMillis = 5_000
    }
}

val queries       = QueryClient(httpClient, "https://api.example.com")
val mutations     = MutationClient(httpClient, "https://api.example.com")
val subscriptions = SubscriptionClient(httpClient, "https://api.example.com")

// Swap CIO for OkHttp on Android, Darwin on iOS, JS on
// browsers — the generated client files don't change.
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 Kotlin 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:
// "Read schema.graphql and generate a Kotlin Ktor
//  client in package com.example.payments. Include
//  the subscription transport."
See the MCP page
Questions

Things people ask.

The questions that come up most when generating Kotlin clients from GraphQL. If yours is not here, run the converter — the answer is usually in the output.

A package of .kt files: one @Serializable data class per object type, one @Serializable enum class per GraphQL enum, a sealed interface per union with each member implementing it, plus QueryClient, MutationClient, and SubscriptionClient classes. Each query and mutation is a suspend fun; each subscription is a function returning Flow<T>. The model types are shared across all three clients.

Try it on your own schema.

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

Open the converter