Overview
GraphQL never had a stdlib story in Go, and the community converged on gqlgen (99designs/gqlgen): write the SDL schema, run the generator, implement typed resolver methods. That schema-first, codegen-heavy approach is very Go — no reflection magic, the generated code is plain readable Go, and the compiler catches schema/resolver drift. The older runtime-first libraries survive as alternatives, but new services overwhelmingly start with gqlgen.
Key points
- gqlgen is the de-facto choice: schema-first,
go run github.com/99designs/gqlgen generateemits models + resolver interfaces, you fill in resolver bodies; type-safe end to end, plugin system for customisation, binds schema types to your own Go models viagqlgen.yml. - graphql-go/graphql: runtime-first — build the schema as Go values
(
graphql.NewObject{...}), resolvers are untypedfunc(p ResolveParams) (interface{}, error). Flexible, no codegen step, but you lose compile-time safety and the schema-as-artifact. - graph-gophers/graphql-go: middle ground — SDL string parsed at startup, resolvers matched to Go methods by reflection; typed-ish without a generate step.
- N+1: solved with dataloaders — batch and cache per-request. vikstrous/dataloadgen (successor to gqlgen's dataloaden) generates typed loaders; graph-gophers/dataloader is the generic runtime version. Wire one loader set into the request context per request, never globally.
- Subscriptions: gqlgen supports them over WebSockets (graphql-ws / graphql-transport-ws protocols) — a resolver returns a channel, gqlgen pumps it to the socket; scaling concerns are the WebSocket ones (see the WebSockets note).
- Federation: gqlgen ships an Apollo Federation v1/v2 plugin (
@key, entity resolvers), so Go services slot into a federated supergraph alongside JVM/Node subgraphs. - Worth it when: many client shapes over shared data, a BFF aggregating several backends, or mobile clients needing to trim payloads. Not worth it for service-to-service calls (use gRPC) or a single-consumer CRUD API (REST is less machinery).
Details
Library comparison
| Library | Style | Type safety | Codegen | Federation |
|---|---|---|---|---|
| gqlgen | Schema-first | Full (generated interfaces) | Yes | Yes (plugin) |
| graphql-go/graphql | Code-as-schema, runtime | None (interface{}) | No | No |
| graph-gophers/graphql-go | SDL + reflection binding | Partial (startup check) | No | Community forks |
The gqlgen loop
gqlgen init scaffolds gqlgen.yml, schema.graphqls and a server; each schema change
is: edit SDL → generate → implement the new resolver stubs. Field resolvers are only
generated where a model field is missing or marked @goField(forceResolver: true) —
everything else binds directly to struct fields, so trivial fields cost nothing. The
generated ExecutableSchema mounts as an ordinary http.Handler, so chi/otelhttp
middleware apply unchanged.
Performance notes
GraphQL's flexibility invites expensive queries: bound them with gqlgen's built-in
complexity limits (extension.FixedComplexityLimit), APQ (automatic persisted queries)
for mobile bandwidth, and per-resolver tracing (gqlgen's OpenTelemetry extension) to find
the resolver actually causing the N+1 before reaching for a loader.
Examples
// Resolver implementing the generated interface; loader from request context.
func (r *queryResolver) Order(ctx context.Context, id string) (*model.Order, error) {
return loaders.From(ctx).OrderByID.Load(ctx, id)
}
// Subscription resolver: return a channel, gqlgen handles the transport.
func (r *subscriptionResolver) OrderUpdated(ctx context.Context, id string) (<-chan *model.Order, error) {
ch := r.bus.Subscribe(ctx, id) // closed when ctx is cancelled
return ch, nil
}
Related
- Backend protocols in Go — the map — parent map; where GraphQL sits among the options.
- REST & HTTP APIs in Go — the default it must beat; both mount as
http.Handler. - API design — the query-language vs resource-model trade-off in the abstract.
- GraphQL on the JVM — the contrast: graphql-java under Spring/SmallRye annotations vs gqlgen's explicit codegen.