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:
- A runtime library (
graphql/,client/,complexity/) imported by every generated server. It must be stable, allocation-conscious, and backward compatible. - A code generator (
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. - A CLI (
main.go, built ongithub.com/urfave/cli/v3) that drives the generator.
Two consequences are worth internalizing before you write anything:
- Generated code is an output, not a source. Do not hand-edit generated files to fix a
bug — fix the templates or the generator and regenerate. Files produced by gqlgen carry the
header
// Code generated by github.com/99designs/gqlgen, DO NOT EDIT.; respect it. (Other generators in the tree —dataloaden,moq,protoc-gen-go— emit their ownDO NOT EDITheaders; the same rule applies.) The fixtures undercodegen/testserver/are regenerated, not edited by hand. - Backward compatibility is a feature. gqlgen is depended on by a large number of public
projects. A change that forces every user to edit generated code, change their config, or
alter resolver signatures is a breaking change and must be treated as one (proposal issue
first, and it targets the
nextbranch — see CONTRIBUTING.md).
_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:
- Don't write N+1 resolvers in examples. A resolver that fetches a related object once per
parent item teaches users to build slow servers. The
dataloaderandbatchresolverexamples exist precisely to show the batch-and-cache pattern — follow them when an example loads related data. - Keep examples minimal and idiomatic. An example should isolate the one feature it
teaches.
large-project-structureis the reference for laying out a bigger gqlgen project;contextpropagationis the reference for threading request-scoped data through resolvers. Don't copy unrelated complexity into a focused example.
- Read the surrounding code first. Match its naming, file organization, error style, and comment density. Consistency beats personal preference; we enforce style with linters, not argument.
- One major concept per file. gqlgen keeps focused files (
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. - Run the toolchain locally before opening a PR. At minimum:
go generate ./...(and in_examples/) if you touched anything that affects output.golangci-lint run— config is in.golangci.yml. We rungofumpt,golines(100-column max),gciimport 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> // reasonwith a reason (nolintlintrequires the explanation).go test ./...for the package you changed.
- Do not commit unrelated churn. A PR that reformats a file, renames variables for taste, or "tidies" code outside the change makes review harder and bisection useless. Keep the diff to the change you are making.
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:
- Implement complexity (buffering, encoding, field collection, dataloading) inside the package; expose as little of it as possible. The best packages hide enormous complexity behind a small surface.
- Make the common case require the fewest calls and the least prior knowledge. Provide sensible defaults; callers should be unaware of options they do not need.
- Pull complexity downward: handle edge cases, defaults, and normalization inside the package. Before adding a new config field or exported option, ask: "Will callers actually know the right value better than gqlgen does?"
- Write the doc comment for an exported function before writing its body. If the comment is long and tangled, the interface is wrong. Writing the comment is a design check.
- Model constraints in types rather than in runtime validation when you can. A type that can only hold valid values removes the need to re-check it everywhere.
Do not:
- Write pass-through methods whose entire body forwards to another call with the same signature.
- Split a function only to hit an arbitrary line count. A long function with a simple interface is fine — it is deep.
- Create a type or function whose interface is nearly as complex as its body.
- Expose internal representation through getters/setters that hand back mutable internal state.
- Add a new exported symbol to the runtime library without considering that you now have to support it. Every export is a promise.
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:
- Write transformations that accept values and return new values. A function that takes parsed schema/config data and returns the data structures to render is testable in isolation.
- Accept dependencies as parameters rather than reaching for globals or reading the filesystem inside the function.
Pure core — do not:
- Put a file read, an
os.Getenv, ortime.Now()inside a function that also contains generation/transformation logic. Move the I/O to the caller and pass the result in. - Design a function so that testing it requires a temp directory or a fixture tree when the logic itself only needs in-memory data. If it does, the function is doing too much — split it.
Imperative shell:
- Keep the shell (the command actions in
main.go, the top-levelapi.Generateflow) 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.
- Push string and name selection onto the data model. When a template chooses between Go
snippets based on a field's properties, that decision belongs in a method on
Field/Object(alongside the existingShort*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. - Push duplicated generated bodies into the runtime. When several templates emit the same
block of Go, move that block into a helper in the
graphqlpackage and have the template call it. This shrinks the generated output, removes copy-paste drift, and makes the behavior unit-testable ingraphqlrather than only via regenerate-and-diff. - The guiding split: template branching that picks a string → a
Field/Objectmethod; duplicated generated logic → agraphqlruntime 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):
- Wrap with context using
fmt.Errorf("...: %w", err)so the cause chain is preserved anderrors.Is/errors.Aswork. The codebase already does this — match it. - Never return
(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. - Do not swallow errors. Letting an error vanish (
_ = doThing(), or an emptyif err != nil {}) hides failures.errcheckruns in CI and will catch most of these; do not silence it without a real reason. - Let errors propagate to a layer that can do something meaningful with them. Do not add catch-all handlers at every internal boundary.
- In the CLI, use
cli.Exit(msg, code)for user-facing failures (asmain.goalready does) rather than callingos.Exitfrom deep in a call stack.
GraphQL errors (the runtime, served to clients):
- Errors returned to clients flow through
github.com/vektah/gqlparser/v2/gqlerrorand thegraphqlpackage'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. - A resolver returning an error is normal control flow, not an exceptional event. Make sure the error carries enough for the presenter to build a useful GraphQL error, and no more.
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.
- Use a private, named key type — never a bare
stringorint. gqlgen declarestype 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. - Read context values through the package's accessor helpers (e.g.
graphql.GetOperationContext,graphql.GetFieldContext) rather than callingctx.Valuedirectly from outside thegraphqlpackage. The helpers are the supported surface; the keys are deliberately unexported. - Don't smuggle required values through context to avoid adding a parameter. Context is for request-scoped, cross-cutting data (deadlines, cancellation, the operation/field context, tracing spans) — not a back channel for arguments a function genuinely needs. If a function requires a value to do its job, take it as an explicit parameter.
- Always propagate the incoming
ctxdownward, never store acontext.Contextin a struct field, and don't passnil— start from the context you were given (only the top of the call tree usescontext.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:
- Prefer passing immutable values between goroutines over sharing mutable state. If two goroutines need to update the same thing, reconsider the design before reaching for a lock.
- When you do need shared mutable state in the runtime, protect it correctly and run the race
detector:
go test -race ./.... The executor's concurrency is exactly the kind of code where a data race hides until production. - Inject
time, randomness, and I/O rather than callingtime.Now()/rand/ the filesystem directly inside logic you want to test deterministically.
Do not:
- Introduce shared mutable state across resolver goroutines without synchronization. A resolver that writes to a map or slice shared across fields is a race.
- Add package-level mutable state (see §9). Globals shared across concurrently-executing resolvers race by default, and gqlgen is actively reducing the globals it already has — don't add more.
- Use
time.Sleepto "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:
- Quality — forcing deliberate thought before writing code. The value is the thinking, not the line of coverage.
- Maintenance — locking in behavior so code can change safely (regression and characterization tests). gqlgen's regenerate-and-diff testserver is exactly this.
- Validation — confirming the result is what users actually need.
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.)
testifyis the assertion library here.github.com/stretchr/testify(requireandassert) is used across the suite. Use it; do not hand-roll assertion helpers or introduce a different assertion framework. Reach forrequirewhen a failed check should stop the test,assertwhen the test can keep going and report multiple failures.- Prefer table-driven tests with
t.Runsubtests. Named subtests are individually targetable (go test -run TestX/case_name) and keepdeferscoped per case. - Call
t.Helper()in test helpers so failures point at the call site, not inside the helper. - Test the exported behavior, not unexported internals, as your primary strategy. If the public API is correct, the internals are correct by definition.
- The golden path for codegen output is regenerate-and-diff. The server in
codegen/testserveris generated bygo 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 underintegration/). - Do not use
time.Sleepin tests. For anything timing-related, use channels, contexts, or the synchronization the code already provides. - Be careful with
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:
- Use names specific enough that a reader at the call site can guess the meaning without
chasing the definition. Make boolean names predicates (
hasResolver, notresolverStatus). - Use one consistent name for a concept everywhere it appears; do not invent synonyms. Do not reuse the same name for dissimilar things.
- Difficulty naming something is a design smell — usually the thing does too much or has an unclear purpose.
Comments:
- Write a doc comment for every exported type, function, and method: what it does, its arguments and return values, side effects, and error conditions. This is also pkg.go.dev documentation that users read.
- Explain why, not what. Don't restate the code; explain the non-obvious reason a
particular approach was chosen — especially in
codegen/andplugin/, where the why ("we emit it this way because gqlparser orders fields like X") is rarely recoverable from the code alone. - Document anything that violates a reader's expectations (a constructor that starts a goroutine, a function that mutates an argument) prominently.
(Ousterhout, Feathers)
- Do not add new global state. Dependencies are explicit. Parts of gqlgen still use
package-level mutable variables — this is legacy we are actively moving away from, not a
pattern to extend. New code must receive what it needs as function parameters or struct
fields rather than reaching for package-level
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. - Don't let complexity accumulate. A single shortcut matters little; hundreds turn a codebase into one nobody can change. Every tactical hack adds a little.
- Leave the code better than you found it — but only within the scope of your change (see §1 on unrelated churn). After your change, the area should look like it was designed with the change in mind.
- Delete dead code. Every line implicitly claims it is in use. Recover from git if you ever need it back.
- Make code obvious, and be consistent — follow the conventions already in the codebase, and let the linters enforce style so humans don't have to.
- Design for performance without adding complexity. The runtime library is on a hot path for every gqlgen user. Simple code is usually fast code (fewer layers, fewer allocations, better cache behavior). But measure before optimizing — add a benchmark, show the improvement, and discard changes that add complexity for no measured gain.
gqlgen's command-line tool is built on github.com/urfave/cli/v3 (see main.go). When adding
or changing commands:
- Keep command
Actionfunctions thin: parse flags, load config, call intoapi/codegen, translate the result into an exit code. Business logic belongs in the packages, not in the command closure. - Use
cli.Exit(msg, code)for clean user-facing failures; reserveos.Exitfor the single top-level handler inmain. - Treat flags and command names as a stable public interface — renaming or removing one is a breaking change for users' scripts and CI.
- Behavior is covered by tests (new or updated), and
go test ./...passes for affected packages. Concurrency-sensitive changes were run with-race. - If output changed:
go generate ./...was run in both the root and_examples/modules, and the regenerated files are committed. -
golangci-lint runis clean (no disabled linters, no unexplained//nolint). - No new global mutable state — dependencies are passed explicitly, not via package-level
vars,init()side effects, or singletons. - The diff contains only the intended change — no unrelated reformatting or renames.
- No generated file was hand-edited; the fix lives in templates/generator/config.
- Breaking changes (resolver signatures, config schema, CLI flags, exported runtime API)
have a proposal issue and target the
nextbranch, per CONTRIBUTING.md. - Exported additions have doc comments; non-obvious decisions have a why comment.
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.