Custom Queries & Mutations¶
GraphLink has supported hand-written GraphQL operation documents since day one. Write a query or mutation directly in your schema file (or in a separate .graphql file) and the generator produces a fully typed client method for it — same as auto-generated ones, with the same caching support.
Where to write them¶
Custom operations live in any file matched by schemaPaths. You can put them in the same file as the schema or split them into separate files:
schema/
schema.graphql ← types, queries, mutations, subscriptions
operations.graphql ← your custom operation documents
fragments.graphql ← shared fragments
All files are merged before generation. GraphLink treats every query, mutation, and subscription block it finds as an operation to generate a client method for.
Basic custom query¶
Write a standard GraphQL operation document. Variable names, field selections, and nesting are all yours to define:
query fetchUserProfile($id: ID!) {
getUser(id: $id) {
id
firstName
lastName
email
}
}
Generated Dart method:
final res = await client.queries.fetchUserProfile(id: '42');
print(res.getUser.firstName);
print(res.getUser.email);
The method name is derived from the operation name (fetchUserProfile). The response type is FetchUserProfileResponse with a typed .getUser field containing only the fields you projected.
Using _all_fields¶
Instead of listing every field manually, use ... _all_fields to select all fields of the return type. GraphLink resolves it to the correct type-specific fragment automatically:
Or use the explicit form if you prefer:
Both are equivalent. _all_fields is resolved based on the return type of the field.
Aliases and projections¶
Any valid GraphQL field selection — with or without aliases — always generates a correctly named and typed response object. The generated field names match your aliases exactly. There is no constraint on what you can project or how you name it.
query fetchCarAndOwner($carId: ID!, $ownerId: ID!) {
vehicle: getCar(id: $carId) {
... _all_fields
}
owner: getUser(id: $ownerId) {
firstName
lastName
}
}
The generated response type has fields named after the aliases:
final res = await client.queries.fetchCarAndOwner(carId: '1', ownerId: '2');
print(res.vehicle.make); // alias "vehicle" → getCar result
print(res.owner.firstName); // alias "owner" → getUser result
Without an alias the field name matches the resolver name. With an alias it matches the alias — always:
query fetchSummary($id: ID!) {
latestCar: getCar(id: $id) { make model }
primaryOwner: getUser(id: $id) { firstName }
count: getCarsCount
}
final res = await client.queries.fetchSummary(id: '1');
res.latestCar.make; // named "latestCar"
res.primaryOwner.firstName; // named "primaryOwner"
res.count; // named "count", typed int
The projected type for each field is generated from the field selection you wrote. If you project { make model } on a Car, GraphLink generates a projected type with exactly those two fields. If you project all fields (via ... _all_fields or explicitly), it reuses the base type.
Multiple resolvers in one query¶
This is one of the most powerful features. A single custom query can call multiple resolvers and get back one unified, typed response:
query fetchDashboard($userId: ID!) {
profile: getUser(id: $userId) {
... _all_fields
}
cars: getCarsByUser(userId: $userId) {
... _all_fields
}
count: getNotificationsCount(userId: $userId)
}
final res = await client.queries.fetchDashboard(userId: session.userId);
print(res.profile.firstName);
print(res.cars.length);
print(res.count);
The server receives a single GraphQL request. The response type FetchDashboardResponse has a typed field for each resolver in the query.
Custom field projection¶
You control exactly which fields come back. GraphLink generates a dedicated projected type matching your selection — not the full schema type:
query fetchCarList($page: PageInput!) {
getCars(page: $page) {
id
make
model
year
}
}
The response contains only id, make, model, and year. GraphLink generates a projected type (e.g. Car_IdMakeModelYear) that holds exactly those fields. Nullability from the schema is preserved on each field.
When a projection selects all fields of a type, GraphLink is smart enough to reuse the base type directly — no duplicate class is generated.
Custom mutations¶
Custom mutations follow the same pattern. Select exactly which fields you want from the response:
mutation registerCar($input: CreateCarInput!) {
createCar(input: $input) {
id
make
model
}
}
final res = await client.mutations.registerCar(
input: CreateCarInput(make: 'Toyota', model: 'Camry', year: 2024),
);
print(res.createCar.id);
Shared fragments¶
Define a fragment once and reuse it across multiple operations:
query fetchCar($id: ID!) {
getCar(id: $id) {
... CarSummary
}
}
query fetchCarsAndOwner($ownerId: ID!) {
getCars(ownerId: $ownerId) {
... CarSummary
}
getUser(id: $ownerId) {
firstName
lastName
}
}
GraphLink resolves fragment spreads and inlines the selected fields into the generated query strings. Your fragments do not need to be in the same file as the operations that use them — any file in schemaPaths is visible to all others.
Selective auto-generation (autoGenerateQueriesFor)¶
autoGenerateQueries: true is convenient for small or medium schemas, but on large ones it can produce hundreds of client methods and megabytes of generated code you'll never use. autoGenerateQueriesFor solves this: list only the operations you actually need, and GraphLink generates methods exclusively for those.
{
"clientConfig": {
"dart": {
"generateAllFieldsFragments": true,
"autoGenerateQueriesFor": {
"queries": ["getUser", "listOrders", "getDashboard"],
"mutations": ["createOrder", "cancelOrder"],
"subscriptions": ["onOrderUpdated"]
}
}
}
}
clientConfig:
dart:
generateAllFieldsFragments: true
autoGenerateQueriesFor:
queries:
- getUser
- listOrders
- getDashboard
mutations:
- createOrder
- cancelOrder
subscriptions:
- onOrderUpdated
The three keys — queries, mutations, subscriptions — are independent. You can list operations under any combination of them; keys you omit (or set to []) produce no auto-generated methods for that operation type.
Build error on unknown names. If a name you list doesn't exist as a root field in the schema, GraphLink aborts with a descriptive error:
This catches typos at generation time rather than at runtime.
Works alongside manual queries. autoGenerateQueriesFor only controls what gets auto-generated from root fields. Any operation document you write by hand in a .graphql file is always generated regardless of this setting — the two approaches compose freely.
Requires generateAllFieldsFragments: true, because auto-generation uses the _all_fields fragments to build query bodies.
Auto-generated query argument limit¶
When autoGenerateQueries is on, GraphLink propagates the arguments of every nested field up to the operation level. For most schemas the number of propagated arguments is small — they are grouped automatically into a single fieldArgs object so the method signature stays clean. On very large schemas (e.g. GitHub's node query), the fieldArgs object can grow to hundreds of fields, producing an object that is unrealistic to construct by hand and difficult to compile.
autoGenerateQueriesArgumentLimit (default 200) is a safety cap: if an auto-generated operation's fieldArgs input would contain more fields than the limit, GraphLink skips that operation entirely and prints a ⚠ warning:
⚠ Auto-generated query "node" skipped: 1219 propagated args exceed the limit of 200.
Write a custom query with a narrower selection, or raise autoGenerateQueriesArgumentLimit in your config.
Hand-written operations are never affected — the cap applies only to operations that GraphLink generates automatically.
What to do when an operation is skipped¶
Option 1 — write a custom query. This is the recommended approach for large operations. Select only the fields you actually need:
query fetchNode($id: ID!) {
node(id: $id) {
... on Repository {
id
name
description
}
}
}
Option 2 — use autoGenerateQueriesFor to auto-generate the operation but restrict which operations are generated overall, reducing total output without raising the limit.
Option 3 — raise the limit. If the generated fieldArgs object is genuinely useful in your case, increase the cap:
Client-side default values (v5.0.0+)¶
GraphQL = value defaults on input fields and on query/mutation arguments are now emitted as real defaults in generated Dart, Java, Kotlin, and TypeScript code — previously they were parsed but silently dropped.
input PageInput {
size: Int = 20
offset: Int = 0
}
type Query {
listVehicles(page: PageInput = { size: 20, offset: 0 }, sortDesc: Boolean = false): [Vehicle!]!
}
const PageInput({this.size = 20, this.offset = 0});
Future<ListVehiclesResponse> listVehicles({
PageInput page = const PageInput(),
bool sortDesc = false,
}) async { /* ... */ }
Callers can omit page and sortDesc entirely and get the schema's declared defaults. The generated .graphqls schema output also preserves the = value syntax when generateSchema: true is set.
Auto-generated query variable names (v5.0.0+)¶
Auto-generated operation variable names changed from bare $<argName> to $<fieldName><ArgName> (with a further type-suffix appended only if the same field+arg name resolves to different types across the schema). This avoids variable collisions across fields and fragments selected in the same auto-generated operation.
query getVehicle($getVehicleId: ID!) {
getVehicle(id: $getVehicleId) { ... _all_fields }
}
This only affects auto-generated operations (autoGenerateQueries / autoGenerateQueriesFor) — the wire-level .graphql query string changes, but the generated client method's parameter name is unaffected and behavior is equivalent. Hand-written operations keep whatever variable names you wrote.
Caching on custom queries¶
@glCache and @glCacheInvalidate work exactly the same on custom operations. Apply them at the operation level or per resolver field:
query fetchDashboard($userId: ID!, $page: PageInput!) @glCache(ttl: "5m", tags: ["dashboard"]) {
profile: getUser(id: $userId) @glCache(ttl: "10m", tags: ["users"]) {
... _all_fields
}
cars: getCarsByUser(userId: $userId) @glCache(ttl: "2m", tags: ["cars"]) {
... _all_fields
}
}
Each resolver is cached independently. If "cars" is invalidated, only that resolver is re-fetched on the next call — profile stays warm. See Caching for the full caching reference.
What gets generated¶
For every custom operation, GraphLink generates:
| Item | Naming convention | Example |
|---|---|---|
| Client method | camelCase of operation name | fetchDashboard(...) |
| Response type | {OperationName}Response |
FetchDashboardResponse |
| Method parameters | one per $variable |
userId, page |
| Response fields | one per resolver / alias | .profile, .cars, .count |
The method lives under client.queries, client.mutations, or client.subscriptions depending on the operation type. Everything is fully typed — no casting, no dynamic maps.
What most tools get wrong¶
Most GraphQL code generators fall apart on three things: interfaces, unions, and subscriptions. GraphLink handles all three, including when they are combined.
Interfaces and inline fragments¶
Given this schema:
interface Animal {
id: ID!
name: String!
}
type Dog implements Animal {
id: ID!
name: String!
breed: String!
}
type Cat implements Animal {
id: ID!
name: String!
indoor: Boolean!
}
type Query {
getAnimal: Animal!
}
Write an inline fragment query projecting different fields per concrete type:
GraphLink generates:
- An abstract base class
Animal_with afromJsonfactory that dispatches on__typename - Concrete classes
Dog_IdNameBreedandCat_IdNameIndoor, each implementingAnimal_ FetchAnimalResponsewithgetAnimaltyped asAnimal_
final res = await client.queries.fetchAnimal();
final animal = res.getAnimal;
switch (animal) {
case Dog_IdNameBreed dog:
print('Dog: ${dog.breed}');
case Cat_IdNameIndoor cat:
print('Cat, indoor: ${cat.indoor}');
}
The compiler knows every possible concrete type. No Map<String, dynamic>. No manual __typename checks. No casting.
Unions¶
Unions work the same way. Each member type gets a concrete class implementing the generated abstract union base:
union SearchResult = Car | Owner | Driver
type Query {
search(term: String!): [SearchResult!]!
}
query runSearch($term: String!) {
search(term: $term) {
... on Car { id make model }
... on Owner { id name email }
... on Driver { id name licenseNumber }
}
}
Generated abstract class SearchResult_ with concrete Car_IdMakeModel, Owner_IdNameEmail, Driver_IdNameLicenseNumber — all dispatched from fromJson based on __typename. The search field on the response is List<SearchResult_> — fully typed, no generics needed at the call site.
Subscriptions¶
Subscriptions generate a Stream of the typed response — not Stream<dynamic>, not Stream<Map<String, dynamic>>:
type Subscription {
newNotification(userId: ID!): Notification!
}
subscription watchNotifications($userId: ID!) {
newNotification(userId: $userId) {
id
message
createdAt
}
}
client.subscriptions.watchNotifications(userId: session.userId).listen((event) {
// event is WatchNotificationsResponse — fully typed
print(event.newNotification.message);
print(event.newNotification.createdAt);
});
The generated WebSocket adapter handles the graphql-ws protocol, connection init, ping/pong, and exponential-backoff reconnect automatically.
All three together¶
The real test: a subscription that returns a union type, requiring inline fragments to distinguish the concrete type at runtime. Most tools give up here. GraphLink generates it correctly:
union FeedEvent = NewMessage | UserJoined | UserLeft
type Subscription {
feedEvents(roomId: ID!): FeedEvent!
}
subscription watchFeed($roomId: ID!) {
feedEvents(roomId: $roomId) {
... on NewMessage { authorName text sentAt }
... on UserJoined { username joinedAt }
... on UserLeft { username leftAt reason }
}
}
client.subscriptions.watchFeed(roomId: room.id).listen((event) {
final feed = event.feedEvents; // typed as FeedEvent_
switch (feed) {
case NewMessage_AuthorNameTextSentAt msg:
chatLog.add('[${msg.authorName}] ${msg.text}');
case UserJoined_UsernameJoinedAt join:
chatLog.add('${join.username} joined');
case UserLeft_UsernameLeftAtReason left:
chatLog.add('${left.username} left: ${left.reason}');
}
});
Every event is dispatched by __typename, every concrete type is fully typed, and the stream reconnects automatically on disconnect. This is what most GraphQL tools describe as "coming soon."