GraphLink reads your schema and generates fully typed client and server code for Dart / Flutter and Java / Spring Boot. No boilerplate. No generics. No drift between schema and code.
In production: 72% of Spring Boot files & 21.5% of a full Flutter codebase are generated — zero hand-written boilerplate.
type Query {
getUser(id: ID!): User!
@glCache(ttl: 300, tags: ["users"])
}
type Mutation {
updateUser(
id: ID!, input: UserInput!
): User! @glCacheInvalidate(tags: ["users"])
}
type User {
id: ID!
name: String!
email: String!
}
// Fully typed. Zero boilerplate.
final res = await client.queries
.getUser(id: '42');
// res.getUser is a typed User — no casting
print(res.getUser.name);
// No generics. No casting. Just types.
GetUserResponse res =
client.queries.getUser("42");
// res.getUser() returns a typed User
System.out.println(
res.getUser().getName());
Sound familiar?
GraphQL is great for describing your API. But between the schema and your first real call, there is a wall of repetitive, error-prone code that has nothing to do with your business logic.
You define User in your schema, then again as a Java class, then again as a Dart model. Three places. Three chances to drift.
A field gets renamed on the backend. The app crashes. The client model was never updated. The schema was the truth — nobody told the code.
Some GraphQL clients force you to pass TypeReference<Response<GetUserData>> every single call. For every single query.
Schema, data class, fromJson, toJson, query string. Every new field is a five-stop tour of your codebase — and one missed stop breaks everything.
Writing fromJson / toJson for the 50th time this year. You know you should automate this. You haven't gotten around to it.
Connecting, handshaking, reconnecting, parsing frames. A full afternoon for something that should be one method call.
The fix
GraphLink turns your schema into production-ready code in seconds. The output is readable, idiomatic, and has no runtime dependency on GraphLink.
Define your types, queries, mutations, subscriptions. Add @glCache where you want built-in caching. The schema is your only source of truth.
Run glink -c config.json. Point it at your schema, pick your target language, and GraphLink writes all the files. That is the entire workflow.
Your IDE sees the generated types immediately. Queries are methods. Responses are typed objects. Subscriptions are streams. No wiring needed.
What a medium-complexity schema generates:
All generated files are yours. No hidden runtime. Stop using GraphLink any time — the files keep working.
Before & after
A single query on a User type with three fields. Multiply this by every query in your app — then you understand why 64% of a real Spring Boot codebase is generated.
// 1. Write the query string manually
String GET_USER = """
query GetUser($id: ID!) {
getUser(id: $id) { id name email }
}
""";
// 2. Write the data class manually
public class User {
private String id;
private String name;
private String email;
public String getId() { return id; }
public void setId(String id) { this.id = id; }
public String getName() { return name; }
public void setName(String n) { this.name = n; }
public String getEmail() { return email; }
public void setEmail(String e) { this.email = e; }
}
// 3. Write the response wrapper manually
public class GetUserData {
private User getUser;
public User getGetUser() { return getUser; }
public void setGetUser(User u) { this.getUser = u; }
}
// 4. Execute — with generics
GraphQLResponse<GetUserData> response = client.execute(
GET_USER,
Map.of("id", userId),
new TypeReference<GraphQLResponse<GetUserData>>() {}
);
User user = response.getData().getGetUser();
// That's it.
User user = client.queries
.getUser(userId)
.getUser();
// 1. Write the data class manually
class User {
final String id;
final String name;
final String email;
User({
required this.id,
required this.name,
required this.email,
});
factory User.fromJson(Map<String, dynamic> json) => User(
id: json['id'] as String,
name: json['name'] as String,
email: json['email'] as String,
);
Map<String, dynamic> toJson() => {
'id': id, 'name': name, 'email': email,
};
}
// 2. Write the query string manually
const getUserQuery = r'''
query GetUser($id: ID!) {
getUser(id: $id) { id name email }
}
''';
// 3. Write the HTTP call and parse manually
final resp = await http.post(
Uri.parse(graphqlUrl),
headers: {'Content-Type': 'application/json'},
body: jsonEncode({
'query': getUserQuery,
'variables': {'id': userId},
}),
);
final data = jsonDecode(resp.body);
final user = User.fromJson(
data['data']['getUser'] as Map<String, dynamic>,
);
// That's it.
final res = await client.queries
.getUser(id: userId);
final user = res.getUser; // typed User
What you get
Every query and mutation becomes a typed method call. Arguments are named parameters. Responses are typed classes. Your IDE autocompletes everything.
For Spring Boot: generates controllers, service interfaces, and repository stubs. Not skeleton stubs — real, compilable code ready for your business logic.
Cache behavior lives in the schema via two directives — not scattered across your client code. Tag-based invalidation. Partial query caching. Offline fallback.
See how it works →Run with -w and GraphLink watches your schema files. Every save triggers instant regeneration. Your IDE sees the updated types without restarting.
Subscriptions are generated alongside queries and mutations. Connection, handshake, message parsing, reconnection — all handled in the generated client.
Generated code has no runtime dependency on GraphLink. Stop using it any time — the files keep compiling and working. You own every line of the output.
Unique to GraphLink
Most GraphQL clients require you to configure caching in application code — separate from the schema, spread across components. GraphLink puts it exactly where the data contract lives.
type Query {
# Cached 5 min, tagged "cars"
getCar(id: ID!): Car!
@glCache(ttl: 300, tags: ["cars"])
# Each field cached independently
getCarAndOwner(
carId: ID!, ownerId: ID!
): CarAndOwner! @glCache(ttl: 60) {
car: getCar(id: $carId)
@glCache(ttl: 300, tags: ["cars"])
owner: getOwner(id: $ownerId)
@glCache(ttl: 120, tags: ["owners"])
}
}
type Mutation {
# Busts all "cars" entries on success
createCar(input: CreateCarInput!): Car!
@glCacheInvalidate(tags: ["cars"])
}
ttl — Time to liveEntries expire after this many seconds. First call hits the network. Subsequent calls are served from cache with no extra code.
tags — Targeted invalidationTag related entries together. A single mutation can evict an entire group. Unrelated cache entries are untouched and stay warm.
Each field in a compound query has its own TTL. GraphLink fetches only what is missing or expired. If only "cars" is invalidated, "owners" is still served from cache.
staleIfOffline — Offline resilienceReturn the expired entry when the network is unavailable instead of throwing. One flag in the schema. Zero extra code in your app.
You've used Java GraphQL clients that require a
TypeReference<GraphQLResponse<GetUserData>>
on every single call. GraphLink generates fully-resolved return types.
The call site reads like ordinary Java code — because it is.
GraphQLResponse<GetUserData> res = client.execute(
GetUserQuery.builder().id("42").build(),
new TypeReference<GraphQLResponse<GetUserData>>() {}
);
User user = res.getData().getGetUser();
User user = client.queries
.getUser("42").getUser();
Get running
# Download for your platform from GitHub Releases:
# github.com/Oualitsen/graphlink/releases/latest
#
# Linux: glink-linux-x86_64
# macOS: glink-macos-arm64
# Windows: glink-windows-x86_64.exe
chmod +x glink-linux-x86_64
mv glink-linux-x86_64 /usr/local/bin/glink
config.json{
"schemaPaths": ["lib/**/*.graphql"],
"mode": "client",
"typeMappings": {
"ID": "String",
"Float": "double",
"Int": "int",
"Boolean": "bool"
},
"outputDir": "lib/generated",
"clientConfig": {
"dart": {
"packageName": "my_app",
"generateAllFieldsFragments": true,
"autoGenerateQueries": true
}
}
}
glink -c config.json
# Or watch mode — regenerates on every schema save:
glink -c config.json -w
# Download for your platform from GitHub Releases:
# github.com/Oualitsen/graphlink/releases/latest
#
# Linux: glink-linux-x86_64
# macOS: glink-macos-arm64
# Windows: glink-windows-x86_64.exe
chmod +x glink-linux-x86_64
mv glink-linux-x86_64 /usr/local/bin/glink
config.json{
"schemaPaths": ["schema/*.gql"],
"mode": "client",
"typeMappings": {
"ID": "String",
"Float": "Double",
"Int": "Integer",
"Boolean": "Boolean"
},
"outputDir": "src/main/java/com/example/generated",
"clientConfig": {
"java": {
"packageName": "com.example.generated",
"generateAllFieldsFragments": true,
"autoGenerateQueries": true
}
}
}
glink -c config.json
# Or watch mode — regenerates on every schema save:
glink -c config.json -w
What's coming
GraphLink is actively developed. The next major target is TypeScript — bringing the same zero-boilerplate experience to JavaScript/TypeScript frontends and Node.js backends. Go and Kotlin targets will follow based on community demand.
Full client with queries, mutations, subscriptions, and built-in caching.
Type-safe client with no generics at the call site and builder-pattern inputs.
Controllers, service interfaces, and repository stubs generated from schema.
Type-safe client for React, Vue, Angular, and Node.js. Same zero-boilerplate philosophy.
Server-side code generation for Node.js GraphQL backends.
Additional targets based on community demand. Star the repo to show interest.
Need a language or framework not on the list?
Request a target →Common questions
No. The generated code has zero runtime dependency on GraphLink. If you stop using GraphLink tomorrow, every generated file continues to compile and work exactly as before. You own the output completely — it is ordinary Dart or Java code.
Never. GraphLink generates fully-resolved return types for every query and mutation. The call site is just client.queries.getUser(id).getUser() — no TypeReference, no casting, no generic parameters. Other Java GraphQL clients often force you to pass new TypeReference<GraphQLResponse<GetUserData>>(){} on every single call. GraphLink does not.
Run glink -c config.json again (or let watch mode pick it up automatically). All affected files are regenerated and your new field is immediately available as a typed property. You edit one file — the schema — and GraphLink handles the rest.
Cache behavior is declared directly in the schema using two directives. @glCache(ttl: 300, tags: ["cars"]) caches a query result for 300 seconds under the tag "cars". @glCacheInvalidate(tags: ["cars"]) on a mutation evicts all entries tagged "cars" when the mutation succeeds. You can also apply @glCache to individual fields inside a compound query so each field is cached independently with its own TTL — if one tag is invalidated, the others stay warm.
Currently: Dart and Flutter (client), Java (client), and Spring Boot (server — controllers, services, repositories). TypeScript support is actively in development. Go and Kotlin targets are planned based on community demand.
Yes. GraphLink is used in production in a large multi-tenant SaaS platform (dialysis clinic management). In that project, 72% of Spring Boot files and 64% of lines are generated — only 135 files (~11.8k lines) were 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.
The generated code is idiomatic, readable, and fully debuggable — it looks exactly like code you would have written by hand. There is no runtime magic, no hidden abstraction layer, and no dependency on GraphLink at runtime.
Yes. Point schemaPaths in your config.json at your existing .graphql files and set outputDir to wherever you want the generated files. GraphLink does not modify any of your existing source files.