RULES.md
This document collects the coding practices we expect in pull requests to gqlgen. It is written to be useful whether you write code by hand or with an AI coding assistant (Claude, Copilot, Cursor, etc.). If you use an agent, point it at this file — it is the standard your PR will be reviewed against, and "the tool generated it that way" is not a justification a reviewer can accept.
The design principles here are synthesized from the public writing of Ben Johnson, Gary Bernhardt, Mat Ryer, Michael Feathers, Mitchell Hashimoto, and John Ousterhout. They are general Go wisdom; this file adapts them to how gqlgen is actually built. Where a general principle and gqlgen's existing conventions disagree, the existing conventions in the codebase win — match the code around you.
For process questions (which branch to target, filing a proposal issue, the contributor license) see CONTRIBUTING.md. For how the test suite is wired together see TESTING.md. This file is about the code itself.
gqlgen is three things at once, and the right approach differs for each:
graphql/, client/, complexity/) imported by every generated
server. It must be stable, allocation-conscious, and backward compatible.codegen/, plugin/, api/, internal/) that reads a GraphQL
schema plus a config and writes Go. Much of its output is generated code that lives in
users' repositories, so its quality and stability are part of our public surface.main.go, built on github.com/urfave/cli/v3) that drives the generator.Two consequences are worth internalizing before you write anything:
// Code generated by github.com/99designs/gqlgen, DO NOT EDIT.; respect it. (Other
generators in the tree — dataloaden, moq, protoc-gen-go — emit their own
DO NOT EDIT headers; the same rule applies.) The fixtures under codegen/testserver/ are
regenerated, not edited by hand.next branch — see CONTRIBUTING.md)._examples module is separate — generate there too_examples/ is its own Go module (github.com/99designs/gqlgen/_examples), not part of
the root module. It exists so contributors can see end-to-end, runnable GraphQL servers
(todo, starwars, dataloader, federation, chat, the subscription example, etc.). If
your change affects generated output, you must regenerate the examples and commit the result:
go generate ./... # root module
cd _examples && go generate ./... # examples module (also invoked by the root //go:generate)
When you add or change behavior, prefer demonstrating it in an existing example over inventing
a new one. A change that leaves the examples stale (committed generated code no longer matches
what go generate produces) will fail review.
The examples are teaching material, so the handwritten code in them must model good practice, not just compile:
dataloader and batchresolver
examples exist precisely to show the batch-and-cache pattern — follow them when an example
loads related data.large-project-structure is the reference for laying out a bigger gqlgen project;
contextpropagation is the reference for threading request-scoped data through resolvers.
Don't copy unrelated complexity into a focused example.graphql/upload.go,
graphql/context_operation.go, …). Put a new concept in a new, well-named file rather than
appending it to an unrelated one; don't split one concept across files for no reason.go generate ./... (and in _examples/) if you touched anything that affects output.golangci-lint run — config is in .golangci.yml. We run gofumpt,
golines (100-column max), gci import ordering, revive, staticcheck, govet,
errcheck, and more. Do not disable a linter to make your code pass; fix the code, or if a
finding is genuinely wrong, add a narrowly-scoped //nolint:<linter> // reason with a
reason (nolintlint requires the explanation).go test ./... for the package you changed.A module's interface should be dramatically simpler than its implementation. (Ousterhout) This is the single most important idea for gqlgen, because so much of the library is consumed by code we generate and by users we never meet.
Do:
Do not:
Red flags (from Ousterhout; all apply here):
| Red Flag | Symptom |
|---|---|
| Shallow Module | Interface not much simpler than implementation |
| Information Leakage | Same design decision duplicated across packages |
| Pass-Through Method | Method only forwards to another with the same signature |
| Overexposure | Common API forces learning rarely-needed features |
| Special-General Mixture | A general mechanism contains special-purpose code |
| Repetition | The same nontrivial code appears in several places |
| Vague / Hard-to-Pick Name | A name too broad to convey meaning signals unclear purpose |
| Comment Repeats Code | The comment says only what the code already shows |
(Bernhardt, Feathers) Code that mixes computation with side effects is hard to test and
reason about. Push computation into pure functions; confine I/O (filesystem, process exec,
network, time.Now()) to a thin outer layer. This matters a lot in codegen/ and plugin/,
where the temptation is to read files, mutate config, and emit output all in one function.
Pure core — do:
Pure core — do not:
os.Getenv, or time.Now() inside a function that also contains
generation/transformation logic. Move the I/O to the caller and pass the result in.Imperative shell:
main.go, the top-level api.Generate flow) a flat
sequence of "load → transform → write" with minimal branching. Logic that creeps into the
shell is logic that is not being tested — treat it as debt and extract it.Use test difficulty as a locating device: a function that is painful to test is pointing at a boundary that should move. Move the boundary and the test becomes easy.
The .gotpl templates should render, not decide. Logic in a template is logic that cannot be
unit-tested and that every reader has to execute in their head.
Field/Object
(alongside the existing Short* helpers) returning the string to emit. A {{ if }} that
selects between two literals is usually a method waiting to be written. Methods are
table-test-able; template branches are not.graphql package and have the template call
it. This shrinks the generated output, removes copy-paste drift, and makes the behavior
unit-testable in graphql rather than only via regenerate-and-diff.Field/Object method;
duplicated generated logic → a graphql runtime helper. Keep the template a thin seam
between the two.gqlgen has two distinct error worlds. Keep them straight.
Internal Go errors (codegen, config loading, CLI):
fmt.Errorf("...: %w", err) so the cause chain is preserved and
errors.Is / errors.As work. The codebase already does this — match it.(nil, nil) from a function that looks up a single thing. A missing value is
either a real value (an explicit "not found" sentinel or a typed result) or an error — never
an untyped nil pair that the caller must guess about._ = doThing(), or an empty if err != nil {}) hides failures. errcheck runs in CI and will catch most of these; do not silence
it without a real reason.cli.Exit(msg, code) for user-facing failures (as main.go already does)
rather than calling os.Exit from deep in a call stack.GraphQL errors (the runtime, served to clients):
github.com/vektah/gqlparser/v2/gqlerror and the
graphql package's error presenter / recovery machinery. Use the existing types and helpers
(graphql.AddError, error presenters, gqlerror.Error) — do not invent a parallel error
representation, and do not leak raw internal Go errors (with file paths, SQL, stack traces)
to GraphQL clients by default.Unrecoverable conditions: genuine invariant violations (a generator reaching a state that should be impossible) may panic rather than threading an error nobody can handle — but this is rare, and the panic message must explain the invariant. Do not use panic as a shortcut for ordinary error handling.
gqlgen is a context-heavy library: every resolver receives a context.Context, and the
graphql package stores request-scoped data on it (the operation context, field context,
response context, tracing). Get this wrong and you introduce subtle bugs that only surface
under real traffic.
string or int. gqlgen declares
type key string (graphql/context_field.go) and stores values under unexported constants
of that type (const operationCtx key = "operation_context"). Two packages that both used a
bare "user" string key would silently collide. Follow the existing pattern.graphql.GetOperationContext, graphql.GetFieldContext) rather than calling ctx.Value
directly from outside the graphql package. The helpers are the supported surface; the keys
are deliberately unexported.ctx downward, never store a context.Context in a struct
field, and don't pass nil — start from the context you were given (only the top of the call
tree uses context.Background()).The contextpropagation example demonstrates the intended way to thread request-scoped data
through resolvers; mirror it.
(Bernhardt) gqlgen executes field resolvers concurrently — this is a core feature, not an
edge case (see the README on worker_limit). Code in the runtime and in resolvers must be safe
under concurrency, and the generated executor already coordinates this carefully.
Do:
go test -race ./.... The executor's concurrency is exactly the kind of code where
a data race hides until production.time, randomness, and I/O rather than calling time.Now() / rand / the filesystem
directly inside logic you want to test deterministically.Do not:
time.Sleep to "fix" a concurrency or ordering problem — it hides the real bug and makes
tests flaky.Red flag — hidden time dependency: a test that passes sometimes and fails others, or needs
a time.Sleep, almost always contains a hidden time.Now() or an unsynchronized race. Find
it; don't paper over it.
(Feathers) Before writing a test, know which goal it serves:
Do not write tests to satisfy a coverage number. High coverage on badly-factored code is still badly-factored code. Tests that are painful to write are reporting a design problem — fix the design, not the test (don't add test-only seams or make fields package-visible just to get a test to compile).
These are gqlgen's real conventions. (They intentionally differ from some "stdlib-only" advice you may have seen elsewhere — follow what the codebase does.)
testify is the assertion library here. github.com/stretchr/testify (require and
assert) is used across the suite. Use it; do not hand-roll assertion helpers or introduce a
different assertion framework. Reach for require when a failed check should stop the test,
assert when the test can keep going and report multiple failures.t.Run subtests. Named subtests are individually
targetable (go test -run TestX/case_name) and keep defer scoped per case.t.Helper() in test helpers so failures point at the call site, not inside the
helper.codegen/testserver is generated by go generate ./... and tests run against it; CI fails if
committed generated code does not match a fresh generation. Tests that run the full codegen
step are slow — use them sparingly and unit-test the logic underneath where you can (see
TESTING.md, including the introspection-diff setup under integration/).time.Sleep in tests. For anything timing-related, use channels, contexts, or
the synchronization the code already provides.t.Parallel() and shared/global state. Use it freely when each test is
fully isolated; avoid it when tests touch shared package-level state, where a failure becomes
ambiguous between a logic bug and a race.When a similar test already exists, copying it and adjusting the relevant lines is usually better than building a clever shared helper — a test that can be understood by reading one function ages better than one that forces a reader to jump across files.
(Ousterhout, Feathers) Code is obvious when a reader's first guess about its behavior is correct. Obscurity is a direct cause of complexity.
Names:
hasResolver, not resolverStatus).Comments:
codegen/ and plugin/, where the why
("we emit it this way because gqlparser orders fields like X") is rarely recoverable from the
code alone.(Ousterhout, Feathers)
vars, init() side effects, or singletons.
Global mutable state is a data race waiting to happen under gqlgen's concurrent resolver
execution (see §6) and it makes code impossible to test in isolation. If it feels like you
need a global, that is the signal to thread the dependency through instead.gqlgen's command-line tool is built on github.com/urfave/cli/v3 (see main.go). When adding
or changing commands:
Action functions thin: parse flags, load config, call into api/codegen,
translate the result into an exit code. Business logic belongs in the packages, not in the
command closure.cli.Exit(msg, code) for clean user-facing failures; reserve os.Exit for the single
top-level handler in main.go test ./... passes for affected
packages. Concurrency-sensitive changes were run with -race.go generate ./... was run in both the root and _examples/
modules, and the regenerated files are committed.golangci-lint run is clean (no disabled linters, no unexplained //nolint).vars, init() side effects, or singletons.next branch, per CONTRIBUTING.md.A note on AI-assisted contributions: generated code is a fine starting point, but you are the author and reviewer of your PR. Read every line, make sure it follows the rules above and the conventions of the surrounding code, and be able to explain why it works. Reviewers will hold the code to this standard regardless of how it was produced.
Attribute the work to the people who directed it, not to the tool. Do not add AI-tool attributions to commits or pull requests — no
Co-authored-by:line naming an assistant, and no "Generated with …" / "Made with …" trailers. The human author(s) who directed and reviewed the change are its sole authors and take full responsibility for it.