Skip to content

GraphLink

You already wrote the schema.

Why are you writing the interface again? And the class?

GraphLink is the only generator that treats your .graphql file as the actual source of truth — not documentation you also hand-copy into two more languages, every time it changes, forever.

Your .graphql schema compiles to a fully typed client and server — for Dart, Flutter, Java, TypeScript, Kotlin, Spring Boot, and Express/Apollo — in milliseconds.

pub.dev License: MIT GitHub Releases

No runtime. No boilerplate. No schema drift.


What stops being your problem

  1. Your database falling over from N+1 queries. A clean-looking resolver fires 500 tiny queries for one list view — invisible in dev, discovered in production. Declare @glSkipOnServer(batch: true) once; GraphLink generates the DataLoader / @BatchMapping for you.
  2. Three hand-maintained copies of every type. Schema, frontend interface, backend class — same shape, written by hand, three times, drifting apart within a sprint. One schema, six typed targets, regenerated in milliseconds.
  3. Untyped response casting. TypeReference<GraphQLResponse<Map<String,Object>>> and its friends. GraphLink returns fully-resolved typed objects on every target — no casting, no generics, no guessing at the shape.
  4. Auth checks copy-pasted into every resolver — and forgotten on the one you added last week. @glIntercept runs one generated hook before any annotated field executes, so there's nothing to forget.
  5. Cache logic that requires a PhD to operate. Apollo Client: typePolicies per type, hand-written merge functions for pagination, and cache.evict/cache.modify calls keyed to the exact normalized field-and-args cache identifier — get one character wrong and stale data just sits there, silently, forever. We ran the numbers, peer-reviewed and everything:

    The Apollo way

    cacheKey ≜ ROOT_QUERY.field( ∮ Σᵢ₌₀ⁿ argsᵢ · e^(iπ·keyArgs) dΩ )
             ⊕ ∬∬ merge(existing, incoming, {args, isReference, readField, storeFieldName}) d(typePolicies)²
             − ∂³(sanity) / ∂cache.modify ∂cache.evict ∂mergeFunction
             + lim_{x→∞} StackOverflow(x)
             ≈ NaN   (converges to "it depends", p < 0.05, campfire required)
    

    Checks out. Nobody on the team understands it. Apollo's a genuinely great library — this part just needs a PhD.


    The GraphLink way

    type Query {
      # cached for 5 minutes, tagged "vehicles" — nothing to configure client-side
      getVehicle(id: ID!): Vehicle! @glCache(ttl: "5m", tags: ["vehicles"])
    }
    
    type Mutation {
      # on success, every "vehicles"-tagged cache entry is evicted automatically
      addVehicle(input: AddVehicleInput!): Vehicle! @glCacheInvalidate(tags: ["vehicles"])
    }
    
  6. Getting locked into a runtime. Generated code has zero dependency on GraphLink. Stop using it tomorrow and everything still compiles.


One schema. Every client. Every language.

Write your API once in GraphQL. Run glink. Every target regenerates in milliseconds.

schema/*.graphql
  ├── lib/generated/                        Flutter / Dart client
  ├── src/main/java/com/app/generated/      Java client
  ├── src/generated/                        TypeScript client
  ├── src/main/kotlin/com/app/generated/    Kotlin client
  └── src/main/java/com/app/generated/      Spring Boot server

Add a field to the schema — run glink — it appears in every client, every language, immediately typed. No hand-written DTOs. No copy-pasted response classes. No drift between frontend and backend.

final res = await client.queries.getVehicle("v-1");
print("${res.getVehicle.brand} ${res.getVehicle.model}");
Vehicle v = client.queries.getVehicle("v-1").getVehicle();
System.out.println(v.getBrand() + " " + v.getModel());
const res = await client.queries.getVehicle("v-1");
console.log(`${res.getVehicle.brand} ${res.getVehicle.model}`);
val v = client.queries.getVehicle("v-1").getVehicle
println("${v.brand} ${v.model}")

The call site is clean in every language. One schema. Zero boilerplate.


Every GraphQL client library sells you the same promise: write a schema, get a client. What they don't show you is what "client" actually meant before code generation caught up to that promise — the hand-written DTO, the casts, the generics, kept in sync with the server by hand, in every language you ship.

Without GraphLink

GraphQLResponse<Map<String, Object>> response =
    client.query(new SimpleGraphQLRequest<>(
        "query getVehicle($id: ID!) { getVehicle(id: $id) { id brand year } }",
        Map.of("id", "42"),
        new TypeReference<GraphQLResponse<Map<String, Object>>>() {}
    ));
@SuppressWarnings("unchecked")
Map<String, Object> vehicleMap =
    (Map<String, Object>) response.getData().get("getVehicle");
String brand = (String) vehicleMap.get("brand");
Integer year = ((Number) vehicleMap.get("year")).intValue();

With GraphLink

Vehicle v = client.queries.getVehicle("42").getVehicle();
System.out.println(v.getBrand() + " " + v.getYear());

Without GraphLink

final response = await http.post(uri, body: jsonEncode({
  'query': query,
  'variables': {'id': id},
}));
final json = jsonDecode(response.body) as Map<String, dynamic>;
final vehicle = json['data']['getVehicle'] as Map<String, dynamic>;
final brand = vehicle['brand'] as String;
final year = vehicle['year'] as int;
// Hope nobody renamed a field on the server last sprint.

With GraphLink

final res = await client.queries.getVehicle('42');
print('${res.getVehicle.brand} ${res.getVehicle.year}');

Without GraphLink

val response = httpClient.post(url) { setBody(payload) }
val json = Json.parseToJsonElement(response.bodyAsText()).jsonObject
val vehicleJson = json["data"]!!.jsonObject["getVehicle"]!!.jsonObject
val vehicle = Vehicle(
    id = vehicleJson["id"]!!.jsonPrimitive.content,
    brand = vehicleJson["brand"]!!.jsonPrimitive.content,
    year = vehicleJson["year"]!!.jsonPrimitive.int
)
// Hand-written data class, hand-written parsing, kept in sync by hand.

With GraphLink

val v = client.queries.getVehicle("42").getVehicle
println("${v.brand} ${v.year}")

Without GraphLink

interface Vehicle { id: string; brand: string; year: number; } // hand-written, drifts from schema

const res = await fetch(url, { method: 'POST', body: JSON.stringify({ query, variables: { id } }) });
const json = await res.json();
const vehicle = json.data.getVehicle as Vehicle; // "as" — TS trusts you, the server doesn't have to
console.log(vehicle.brand, vehicle.year);

With GraphLink

const res = await client.queries.getVehicle("42");
console.log(res.getVehicle.brand, res.getVehicle.year);

It's not only the typed fetch. The same gap shows up in subscriptions (hand-rolled reconnect/backoff vs. an adapter that reconnects forever, out of the box) and in caching (a bespoke TTL/tag cache re-implemented per language vs. @glCache(ttl: "2m", tags: ["vehicles"]) declared once in the schema). Multiply all of it by every query, every mutation, every language your stack touches, and the real cost of "no codegen" shows up: hundreds of hand-written DTOs and a schema that quietly drifts from the code that's supposed to implement it.


1. Cache control lives in the schema

Annotate any query or mutation directly in your .graphql file. The generated client handles TTL, tag-based invalidation, partial query caching, and offline fallback automatically — across every target language.

type Query {
  getVehicle(id: ID!): Vehicle! @glCache(ttl: "2m", tags: ["vehicles"])
  listVehicles: [Vehicle!]!     @glCache(ttl: "1m", tags: ["vehicles"])
}

type Mutation {
  addVehicle(input: AddVehicleInput!): Vehicle! @glCacheInvalidate(tags: ["vehicles"])
}

The second call is served from cache. addVehicle evicts the "vehicles" tag automatically. No cache layer to wire up. No manual invalidation logic. The schema says it; the generated client does it.


2. Subscriptions — with auto-reconnect out of the box

This is where most GraphQL client libraries fall short: they generate a WebSocket subscription method but leave reconnection entirely to you. Network drops, server restarts, idle timeouts — your app silently stops receiving events.

GraphLink generates a default WebSocket adapter for every target — with auto-reconnect enabled by default. The connection drops, it comes back, your Flow / Stream / Observable keeps emitting. No reconnection logic to write. No silent failures.

scope.launch {
    client.subscriptions.userCreated().collect { event ->
        println("New user: ${event.userCreated.name}")
    }
}
client.subscriptions.onUserCreated().listen((event) {
  print("New user: ${event.userCreated.name}");
});
client.subscriptions.userCreated().subscribe(event => {
  console.log("New user:", event.userCreated.name);
});

The generated adapter speaks the graphql-ws protocol and handles the full connection lifecycle — connection_init, connection_ack, subscribe, ping/pong, and reconnect — all covered by integration tests.


3. The server is generated too

GraphLink isn't only a client generator. Switch to mode: server and point it at your Spring Boot project. It generates controllers, service interfaces, input classes, and DTOs — wired up to the schema, ready to implement.

schema/*.graphql  →  @SchemaMapping controllers
                     Service interfaces to implement
                     Input classes with null-safety
                     Type DTOs

You implement the business logic. GraphLink handles the plumbing.


4. Generated code you own completely

The output is ordinary Dart, Java, TypeScript, or Kotlin — no runtime dependency on GraphLink whatsoever. Stop using it tomorrow and everything keeps compiling. Commit the output to your repo, vendor it, ship it. It's just code.


In production

Measured in Optidialysis — a multi-tenant dialysis clinic management platform.

72% of the Spring Boot backend — generated. Controllers, service interfaces, DTOs, input classes, enums — all from one .graphql file. Only 135 files (~11.8k lines) written by hand across the entire backend.

What Generated
Spring Boot files 72%
Spring Boot lines 64%
Flutter codebase 21.5%

Every generated file compiles, ships, and runs with zero runtime dependency on GraphLink.


Supported targets

Stable: Dart · Flutter · Java · Spring Boot · TypeScript · Kotlin · Express / Apollo

Planned: Go


Installation

Download the single self-contained binary — no JVM, no package manager required.

curl -fsSL https://github.com/Oualitsen/graphlink/releases/latest/download/glink-macos-arm64 -o glink
chmod +x glink && sudo mv glink /usr/local/bin/glink
curl -fsSL https://github.com/Oualitsen/graphlink/releases/latest/download/glink-macos-x64 -o glink
chmod +x glink && sudo mv glink /usr/local/bin/glink
curl -fsSL https://github.com/Oualitsen/graphlink/releases/latest/download/glink-linux-x64 -o glink
chmod +x glink && sudo mv glink /usr/local/bin/glink

Download glink-windows-x64.exe and add it to your PATH.

flutter pub add --dev graphlink
# or
dart pub add --dev graphlink
docker run --rm -v "$PWD":/workspace -w /workspace \
  oualitsen/graphlink:latest -c glink.json

Multi-arch (amd64/arm64) image: oualitsen/graphlink. No JVM, no local binary — good fit for CI.


Quick Start

1. Write your schema

type Vehicle {
  id: ID!
  brand: String!
  model: String!
  year: Int!
  fuelType: FuelType!
}

enum FuelType { GASOLINE DIESEL ELECTRIC HYBRID }

input AddVehicleInput {
  brand: String!
  model: String!
  year: Int!
  fuelType: FuelType!
}

type Query {
  getVehicle(id: ID!): Vehicle!  @glCache(ttl: "2m", tags: ["vehicles"])
  listVehicles: [Vehicle!]!      @glCache(ttl: "1m", tags: ["vehicles"])
}

type Mutation {
  addVehicle(input: AddVehicleInput!): Vehicle! @glCacheInvalidate(tags: ["vehicles"])
}

2. Configure

Pick your target and drop a glink.yaml next to your schema:

schemaPaths:
  - lib/**/*.graphql
mode: client
typeMappings:
  ID: String
  Float: double
  Int: int
  Boolean: bool
outputDir: lib/generated
clientConfig:
  dart:
    packageName: my_app
schemaPaths:
  - schema/*.graphql
mode: client
typeMappings:
  ID: String
  Float: Double
  Int: Integer
  Boolean: Boolean
outputDir: src/main/java/com/example/generated
clientConfig:
  java:
    packageName: com.example.generated
schemaPaths:
  - schema/*.graphql
mode: client
typeMappings:
  ID: string
  Float: number
  Int: number
  Boolean: boolean
outputDir: src/generated
clientConfig:
  typescript:
    httpAdapter: fetch
schemaPaths:
  - schema/*.graphql
mode: client
typeMappings:
  ID: String
  Float: Double
  Int: Int
  Boolean: Boolean
outputDir: src/main/kotlin/com/example/generated
clientConfig:
  kotlin:
    packageName: com.example.generated
schemaPaths:
  - src/main/resources/graphql/*.graphqls
mode: server
typeMappings:
  ID: String
  Float: Double
  Int: Integer
  Boolean: Boolean
outputDir: src/main/java/com/example/generated
serverConfig:
  spring:
    basePackage: com.example.generated
schemaPaths:
  - schema/*.graphql
mode: server
typeMappings:
  ID: string
  Float: number
  Int: number
  Boolean: boolean
outputDir: src/generated
serverConfig:
  expressApollo:
    port: 4000
    graphqlPath: /graphql

3. Generate

glink       # auto-discovers glink.yaml / glink.json / glink.yml
glink -w    # watch mode — regenerate on every save

→ See Getting Started for a complete walkthrough per language.


FAQ

Does the generated code have a runtime dependency on GraphLink?

None. If you stop using GraphLink tomorrow, every generated file continues to compile and work exactly as before. The output is ordinary Dart, Java, TypeScript, or Kotlin — you own it completely.

What happens when I add a field to the schema?

Run glink (or let glink -w pick it up automatically). All affected files are regenerated and the new field is immediately available as a typed property in every target. One file to edit — GraphLink handles the rest.

How does the built-in caching work?

Cache behaviour is declared in the schema using two directives. @glCache(ttl: "5m", tags: ["cars"]) caches a query result for 5 minutes under the tag "cars". @glCacheInvalidate(tags: ["cars"]) on a mutation evicts all entries tagged "cars" when the mutation succeeds. Individual fields inside a compound query can each carry their own TTL — if one tag is invalidated, the others stay warm. See Caching for the full reference.

Is GraphLink production-ready?

Yes. In one production deployment (multi-tenant SaaS, dialysis clinic management), 72% of Spring Boot files and 64% of lines are generated — only 135 files written by hand across the entire backend. On the Flutter side, 21.5% of the codebase is generated, covering all DTOs, input classes, enums, and GraphQL client wiring.

Can I use GraphLink with an existing project?

Yes. Point schemaPaths at your existing .graphql files and set outputDir to wherever you want the generated files. GraphLink does not touch any of your existing source files.

Does it work for full-stack monorepos?

Yes — this is the primary use case. Run glink with different config files (or different outputDir values) to generate the Flutter client, the Java client, and the Spring Boot server from the same schema. One schema, multiple configs, all in sync.


Documentation

  • Philosophy — Why pure code generation? Why no runtime abstractions?
  • Getting Started — Zero to generated code in 5 minutes.
  • Dart / Flutter Client — Typed queries, mutations, subscriptions. Adapter pattern.
  • Java Client — No generics. No casting. Builder pattern on all inputs.
  • TypeScript Client — Typed client for Angular, React, Vue, and Node.
  • Kotlin Client — Coroutine-based client with data classes and kotlinx.serialization.
  • Spring Boot — Generated controllers, service interfaces, types, inputs.
  • Express / Apollo — Generated resolvers, service interfaces, DataLoader batch mappings.
  • Caching@glCache and @glCacheInvalidate. Tag-based invalidation.
  • Directives — Complete reference for all GraphLink directives.
  • Configuration — Every glink.json / glink.yaml option explained.
  • AI Agents — Ready-to-copy instructions for Claude Code, Gemini CLI, and others.

GraphLink — MIT License — graphlink.dev

Source github.com/Oualitsen/graphlink
Package pub.dev/packages/graphlink
Releases github.com/Oualitsen/graphlink/releases
Changelog CHANGELOG.md
Bug reports github.com/Oualitsen/graphlink/issues
Discussions github.com/Oualitsen/graphlink/discussions
Author github.com/Oualitsen