Go GraphQL
GraphQL moves query planning to the client. That is the feature and the danger: one innocuous query can become ten thousand database round trips, and one over-permissive field can leak another tenant's data. Both are solved at the server, not in the schema review.
Detailed reference material, loaded on demand:
references/errors-auth-testing.md— error presenter, field authorization directive, client-driven tests with query-count asserts.
Read a reference file only when the summary below is not enough.
1. Schema-First with gqlgen
The .graphql schema is the source of truth. gqlgen generates models,
resolver stubs, and the execution layer from it.
bashgo get -tool github.com/99designs/gqlgen go tool gqlgen init # once go tool gqlgen generate # after every schema change
yaml# gqlgen.yml — bind generated types to your own models models: User: model: github.com/myorg/app/internal/domain.User ID: model: - github.com/99designs/gqlgen/graphql.ID - github.com/99designs/gqlgen/graphql.Int64
Bind domain types explicitly. Left to itself gqlgen generates a parallel set of anaemic structs, and every resolver becomes a mapping function.
Commit generated code, and fail CI when it is stale:
bashgo tool gqlgen generate && git diff --exit-code
Never edit generated.go or models_gen.go. resolver.go and the
*.resolvers.go files are yours.
2. Resolvers Stay Thin
A resolver translates a GraphQL request into a service call. It contains no business logic and no SQL.
gofunc (r *queryResolver) User(ctx context.Context, id string) (*domain.User, error) { u, err := r.users.Find(ctx, id) if errors.Is(err, domain.ErrNotFound) { return nil, nil // nullable field: absent, not an error } if err != nil { return nil, fmt.Errorf("find user %s: %w", id, err) } return u, nil }
Inject dependencies through the Resolver struct, never through package
globals:
gotype Resolver struct { users UserService orders OrderService loader *Loaders }
Always propagate ctx. It carries the request deadline, the authenticated
principal, and the per-request dataloaders.
3. The N+1 Problem — the one that matters
A field resolver on a list type runs once per element.
go// ❌ 1 query for the orders, then N queries for the users func (r *orderResolver) Customer(ctx context.Context, obj *domain.Order) (*domain.User, error) { return r.users.Find(ctx, obj.CustomerID) }
Batch with a dataloader. It collects the keys requested within a short window and issues one query.
goimport "github.com/vikstrous/dataloadgen" type Loaders struct { UserByID *dataloadgen.Loader[string, *domain.User] } func NewLoaders(s UserService) *Loaders { return &Loaders{ UserByID: dataloadgen.NewLoader(func(ctx context.Context, ids []string) ([]*domain.User, []error) { return s.FindMany(ctx, ids) // ONE query for all ids }, dataloadgen.WithWait(time.Millisecond)), } } // ✅ 1 query for the orders, 1 for all customers func (r *orderResolver) Customer(ctx context.Context, obj *domain.Order) (*domain.User, error) { return loadersFrom(ctx).UserByID.Load(ctx, obj.CustomerID) }
Loaders are per request, installed by middleware. A process-wide loader caches across users and leaks data between them.
gofunc withLoaders(svc UserService, next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { ctx := context.WithValue(r.Context(), loadersKey{}, NewLoaders(svc)) next.ServeHTTP(w, r.WithContext(ctx)) }) }
The batch function must return results in the order of the keys it was given, with a nil entry and an error per missing key. Returning a shorter slice silently misaligns every result.
4. Bound Every Query
A public GraphQL endpoint without limits is a denial-of-service endpoint.
gosrv := handler.New(generated.NewExecutableSchema(cfg)) srv.AddTransport(transport.POST{}) srv.SetQueryCache(lru.New[*ast.QueryDocument](1000)) srv.Use(extension.FixedComplexityLimit(300)) srv.Use(extension.AutomaticPersistedQuery{Cache: lru.New[string](100)})
- Complexity limit — assign a cost per field, higher for list fields with
a large
first. Start at a number your slowest legitimate query fits under, then measure. - Depth — recursive types (
user { orders { customer { orders ... } } }) must be bounded. gqlgen has no built-in depth limit; enforce it in an operation middleware. - Pagination is mandatory on every list field. A field returning an unbounded list is a schema bug.
- Introspection is only enabled if you install
extension.Introspection. Do not install it in production, or gate it behind an authenticated role. - Persisted queries let a public client send a hash instead of a document, so the server executes only queries you shipped.
Set srv.AroundOperations to enforce a per-operation timeout, and always run
behind an http.Server with ReadTimeout and WriteTimeout set.
5. Errors
GraphQL returns 200 with an errors array. Never leak internals into it.
Install srv.SetErrorPresenter: map known domain errors (validation,
not found) to their message plus a stable extensions.code, and log
everything else server-side while returning internal server error with
code INTERNAL. Example: references/errors-auth-testing.md.
Use srv.SetRecoverFunc to convert a resolver panic into an error instead of
killing the connection, and log it with the stack.
Remember the nullability rule: an error on a non-null field nulls out its nearest nullable ancestor. Make a field non-null only when it can never legitimately be absent.
6. Authorization Belongs on the Field
Object-level checks are not enough — a client can reach an object through several paths.
Declare a @hasRole (or similar) directive on sensitive fields and
implement it as a check against the principal in the context; return
access denied without detail. Example: references/errors-auth-testing.md.
Authenticate in HTTP middleware, before the GraphQL handler. Authorize in the directive or the resolver, using the principal from the context — never from a query argument.
7. Testing
Drive the real schema through gqlgen's test client and assert on the
decoded response. For every resolver behind a dataloader, also assert the
query count — that is the only way an N+1 regression fails a build rather
than a dashboard. Examples: references/errors-auth-testing.md.
Verification Checklist
- Schema is the source of truth; generated files are committed and CI-checked
- Generated types bind to domain models via
gqlgen.yml - Resolvers contain no business logic and always propagate
ctx - Every list-field resolver that fetches by ID goes through a dataloader
- Dataloaders are constructed per request, never shared across requests
- Batch functions return one result per key, in key order
- A complexity limit and a depth bound are configured and tested
- Every list field is paginated
- Introspection is disabled or role-gated in production
- An error presenter strips internal errors; a recover func is installed
- Authorization is enforced per field, from the context principal
- A test asserts the query count for at least one batched field


