Getting Started
EXPERIMENTAL. Options and generated code shapes may change without notice. See the README.
This guide walks you through generating a DataLoader-based batch loader from a Connect RPC.
Prerequisites
- Node.js (v18 or later recommended)
- Buf CLI
@bufbuild/protobufv2.x and@connectrpc/connect(protoc-gen-dataloader only supports protobuf-es v2 — there is noprotobuf_liboption)
Installation
npm install protoc-gen-dataloader @proto-graphql/connect-runtime
# or
pnpm add protoc-gen-dataloader @proto-graphql/connect-runtimeYou also need @bufbuild/protoc-gen-es (v2) to generate the protobuf-es message/service types that protoc-gen-dataloader’s output imports:
npm install --save-dev @bufbuild/protoc-gen-esProject Setup
1. Create buf.yaml
# proto/buf.yaml
version: v2
deps:
- buf.build/proto-graphql/proto-graphql2. Create buf.gen.yaml
Run protoc-gen-es (v2) to generate the message/service types, then protoc-gen-dataloader to generate the loaders:
# proto/buf.gen.yaml
version: v2
plugins:
# Generate protobuf-es v2 message + service types
- local: protoc-gen-es
out: ../src/__generated__/proto
opt:
- target=ts
# Generate DataLoader batch loaders
- local: protoc-gen-dataloader
out: ../src/__generated__/dataloader
opt:
- import_prefix=../proto3. Create Your Proto File
Annotate a unary BatchGet-style RPC with (graphql.rpc).batch:
// proto/example/user_service.proto
syntax = "proto3";
package example;
import "graphql/schema.proto";
message User {
string id = 1;
string name = 2;
}
message BatchGetUsersRequest {
repeated string ids = 1;
}
message BatchGetUsersResponse {
repeated User users = 1;
}
service UserService {
// key_field ("ids") and entity_field ("users") are inferred here — no
// explicit fields needed for those. entity_key must be set explicitly for
// now (a `@key`-based fallback is planned once federation support lands
// upstream). See the annotation reference for when inference fails.
rpc BatchGetUsers(BatchGetUsersRequest) returns (BatchGetUsersResponse) {
option (graphql.rpc).batch = { entity_key: "id" };
}
}4. Generate Code
cd proto
buf dep update
buf generateThis produces src/__generated__/dataloader/example/user_service.pb.dataloader.ts, exporting batchGetUsersLoader.
Wire Up the Context
Every generated loader accessor takes a context shaped like ProtoGraphqlConnectContext as its first argument — an object with a protoGraphql.transport (a Connect Transport):
// src/context.ts
import type { ProtoGraphqlConnectContext } from "@proto-graphql/connect-runtime";
import { createConnectTransport } from "@connectrpc/connect-node";
const transport = createConnectTransport({
baseUrl: "http://localhost:8080",
});
export function createContext(): ProtoGraphqlConnectContext {
return { protoGraphql: { transport } };
}Your own context type does not need to extend ProtoGraphqlConnectContext — any object that is structurally shaped like it works (a protoGraphql: { transport, ... } property alongside whatever else your app’s context carries).
For tests, swap in an in-memory transport with createRouterTransport instead of hitting a real server:
import { createRouterTransport } from "@connectrpc/connect";
import { UserService } from "./__generated__/proto/example/user_service_pb";
const transport = createRouterTransport(({ service }) => {
service(UserService, {
async batchGetUsers(req) {
return { users: req.ids.map((id) => ({ id, name: `user-${id}` })) };
},
});
});
const ctx = { protoGraphql: { transport } };Use the Generated Loader
Import the loader accessor and call .load(key) (or .loadMany(keys)) with your context:
import { batchGetUsersLoader } from "./__generated__/dataloader/example/user_service.pb.dataloader";
const user = await batchGetUsersLoader(ctx).load("user-1"); // User | null
const users = await batchGetUsersLoader(ctx).loadMany(["user-1", "user-2"]);Concurrent .load() calls against the same ctx within a tick merge into a single BatchGetUsers RPC call, regardless of how many resolvers requested a key.
Loader Params
If the RPC’s request has fields besides the key list, load/loadMany/loader take the params as a trailing argument — not the accessor itself, which always takes just ctx. Whether params is optional or required mirrors the field’s presence in the request (see Generated Code Reference):
message BatchGetUsersWithLocaleRequest {
repeated string ids = 1;
optional string locale = 2; // optional => params stays optional
}// params omitted, {}, and undefined are all equivalent and share one batch
await batchGetUsersWithLocaleLoader(ctx).load("user-1");
// a distinct params value (by content) starts a separate batch
await batchGetUsersWithLocaleLoader(ctx).load("user-1", { locale: "ja" });Calling the same loader with varying params effectively splits it into one batch per distinct params value — see Generated Code Reference for the full semantics. batchGetUsersWithLocaleLoader(ctx) itself is cheap to call repeatedly (it’s memoized per ctx), so there’s no need to hold onto the returned wrapper — though doing so is fine too.
Next Steps
- Configuration — every plugin parameter
- Generated Code Reference — entity / group / params variants from real generated output
(graphql.rpc).batchannotation reference — inference rules and validation errors