rgoussu@goussu: ~/library/go/protocols
~/library/go/protocols cat graphql.md

GraphQL in Go

# gqlgen's schema-first codegen as the de-facto GraphQL stack, the runtime-first alternatives, dataloaders, subscriptions and federation.

Conceptsaved 2026-08-09 #go#protocols#graphql#api-design#codegen

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 generate emits 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 via gqlgen.yml.
  • graphql-go/graphql: runtime-first — build the schema as Go values (graphql.NewObject{...}), resolvers are untyped func(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