go generate based graphql server library
# Contributing Code to gqlgen: Engineering Guidelines
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](./CONTRIBUTING.md). For how the test suite is wired together see
[TESTING.md](./TESTING.md). This file is about the code itself.
______________________________________________________________________
## 0. What gqlgen Is โ and Why It Changes the Rules
gqlgen is three things at once, and the right approach differs for each:
1. **A runtime library** (`graphql/`, `client/`, `complexity/`) imported by every generated
server. It must be stable, allocation-conscious, and backward compatible.
2. **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.
3. **A CLI** (`main.go`, built on `github.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 own
`DO NOT EDIT` headers; the same rule applies.) The fixtures under `codegen/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 `next` branch โ see CONTRIBUTING.md).
### The `_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:
```sh
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 `dataloader` and `batchresolver`
examples 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-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.
______________________________________________________________________
## 1. Before You Write Code
- **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`](./.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.
- **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.
______________________________________________________________________
## 2. Interface Design
*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 |
______________________________________________________________________
## 3. Separate Computation from I/O (Functional Core, Imperative Shell)
*(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`, or `time.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-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.
### Keep templates thin
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 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.
- **Push duplicated generated bodies into the runtime.** When several templates emit the same
block of Go, move that block into a helper in the `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.
- The guiding split: *template branching that picks a string โ a `Field`/`Object` method;
duplicated generated logic โ a `graphql` runtime helper.* Keep the template a thin seam
between the two.
______________________________________________________________________
## 4. Error Handling
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 and
`errors.Is` / `errors.As` work. 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 empty `if err !=
nil {}`) hides failures. `errcheck` runs 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 (as `main.go` already does)
rather than calling `os.Exit` from deep in a call stack.
**GraphQL errors** (the runtime, served to clients):
- Errors returned to clients flow through `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.
- 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.
______________________________________________________________________
## 5. Context Discipline
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 `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.
- **Read context values through the package's accessor helpers** (e.g.
`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.
- **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 `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.
______________________________________________________________________
## 6. Concurrency
*(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 calling `time.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.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.
______________________________________________________________________
## 7. Testing
### Philosophy
*(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).
### Mechanics โ how gqlgen actually tests
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.
- **Prefer table-driven tests with `t.Run` subtests.** Named subtests are individually
targetable (`go test -run TestX/case_name`) and keep `defer` scoped 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/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/`).
- **Do not use `time.Sleep` in 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.
______________________________________________________________________
## 8. Naming, Comments, and Making Code Obvious
*(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`, not `resolverStatus`).
- 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/` and `plugin/`, 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.
______________________________________________________________________
## 9. Design Philosophy
*(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 `var`s, `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.
______________________________________________________________________
## 10. The CLI
gqlgen's command-line tool is built on `github.com/urfave/cli/v3` (see `main.go`). When adding
or changing commands:
- Keep command `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.
- Use `cli.Exit(msg, code)` for clean user-facing failures; reserve `os.Exit` for the single
top-level handler in `main`.
- Treat flags and command names as a stable public interface โ renaming or removing one is a
breaking change for users' scripts and CI.
______________________________________________________________________
## 11. Submitting Your Change โ Checklist
- [ ] 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 run` is clean (no disabled linters, no unexplained `//nolint`).
- [ ] No new global mutable state โ dependencies are passed explicitly, not via package-level
`var`s, `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 `next` branch, 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.