Back to Gqlgen

Contributing Code to gqlgen: Engineering Guidelines

RULES.md

0.17.9324.8 KB
Original Source

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. For how the test suite is wired together see 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. 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 FlagSymptom
Shallow ModuleInterface not much simpler than implementation
Information LeakageSame design decision duplicated across packages
Pass-Through MethodMethod only forwards to another with the same signature
OverexposureCommon API forces learning rarely-needed features
Special-General MixtureA general mechanism contains special-purpose code
RepetitionThe same nontrivial code appears in several places
Vague / Hard-to-Pick NameA name too broad to convey meaning signals unclear purpose
Comment Repeats CodeThe 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 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.

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 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 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.