← GophersCONTENT HISTORY

Update to Gophers

Snapshot Sep 30, 2026 · 23:14 UTC · version 0.1.0

Collection source: not recorded for this historical snapshot.

WHAT CHANGED · RULE-BASED ANALYSIS

First saved snapshot

No earlier snapshot is available to establish a change.

Compare saved observations

Download comparison JSON
Full technical diff · 0 changed fields
Full snapshot data
{
  "description": "Use when building or reviewing a GraphQL API in Go. Covers library choice (gqlgen vs graph-gophers), schema design (nullability, pagination, mutation envelopes), thin resolver pattern, per-request DataLoaders for N+1, authentication via context plus schema directives, error presenters, subscription lifecycle (context cancellation), and production hardening (complexity limits, introspection gating). Apply when working with github.com/99designs/gqlgen or github.com/graph-gophers/graphql-go.",
  "included_files": [
    {
      "relative_path": "agents/openai.yaml",
      "size_in_bytes": 201
    },
    {
      "relative_path": "references/anti-patterns.md",
      "size_in_bytes": 3962
    },
    {
      "relative_path": "references/dataloaders.md",
      "size_in_bytes": 4615
    },
    {
      "relative_path": "references/gqlgen.md",
      "size_in_bytes": 4602
    },
    {
      "relative_path": "references/graph-gophers.md",
      "size_in_bytes": 3713
    }
  ],
  "name": "go-graphql",
  "skill_md_contents": "---\nname: go-graphql\ndescription: \"Use when building or reviewing a GraphQL API in Go. Covers library choice (gqlgen vs graph-gophers), schema design (nullability, pagination, mutation envelopes), thin resolver pattern, per-request DataLoaders for N+1, authentication via context plus schema directives, error presenters, subscription lifecycle (context cancellation), and production hardening (complexity limits, introspection gating). Apply when working with github.com/99designs/gqlgen or github.com/graph-gophers/graphql-go.\"\nlicense: MIT\ncompatibility: \"Designed for Claude Code or similar AI coding agents. Requires Go 1.21+. gqlgen v0.17+ or graph-gophers/graphql-go v1.5+.\"\nallowed-tools: Read Edit Write Glob Grep Bash(go:*) Bash(golangci-lint:*)\n---\n\n# Go GraphQL\n\nBoth production-grade Go GraphQL libraries are schema-first: write SDL (`.graphql`), bind Go resolvers. Pick the library, write the schema deliberately, and treat DataLoaders + complexity limits as non-optional.\n\n## Core Rules\n\n1. **Schema is the contract.** Design nullability and pagination once; clients depend on it forever. A change from nullable to non-null is a breaking change.\n2. **Resolvers are thin.** Translate GraphQL input → domain call → GraphQL output. No SQL, no business logic.\n3. **DataLoaders are per-request.** Construct in HTTP middleware, stash in `context`. A package-level DataLoader is a cross-tenant data leak.\n4. **Authenticate in middleware, authorize in the schema.** HTTP middleware extracts identity; schema directives (or resolver checks) enforce per-field rules.\n5. **Subscriptions respect context.** Every subscription goroutine selects on `ctx.Done()` and `defer close(ch)`. Otherwise a disconnected client leaks a goroutine forever.\n6. **Production limits are non-optional.** Set complexity caps; gate introspection by environment; never expose raw internal errors.\n\n## Library Decision\n\n| Library | Approach | Type safety | Build step | Pick when |\n|---|---|---|---|---|\n| `github.com/99designs/gqlgen` | Codegen | Compile-time | `go generate` | Large schemas, Federation, strict types |\n| `github.com/graph-gophers/graphql-go` | Reflection | Parse-time | None | Small/medium schemas, simple pipeline |\n| `github.com/graphql-go/graphql` | Code-first | Runtime | None | **Avoid** — verbose, no SDL |\n\n> Read [references/gqlgen.md](references/gqlgen.md) for the codegen workflow, `gqlgen.yml`, DataLoaders, and Federation.\n> Read [references/graph-gophers.md](references/graph-gophers.md) for the reflection model, type mapping, and tracing.\n\n## Schema Design\n\n```graphql\ntype User {\n  id: ID!                # opaque scalar; never expose Int\n  email: String!         # server can always return this → non-null\n  bio: String            # may be unset → nullable\n  posts(first: Int = 10, after: String): PostConnection!\n}\n\ntype CreateUserPayload {  # mutation envelope: business errors as data\n  user: User\n  errors: [UserError!]!\n}\n\ntype PostConnection {     # Relay cursor pagination\n  edges: [PostEdge!]!\n  pageInfo: PageInfo!\n}\n```\n\n**Nullability rule.** A field is `!` only when the server can *always* return a value. A resolver error on a non-null field nulls the parent object — cascade failures. Nullable fields null only themselves.\n\n**Pagination.** Cursor connections beat offset pagination on large or write-heavy datasets — cursors are stable under concurrent inserts.\n\n**Mutation envelopes.** Wrap mutation results so business-level errors (validation, conflict) become first-class data instead of polluting the top-level `errors` array.\n\n## Thin Resolvers\n\n```go\n// Good — resolver translates and delegates.\nfunc (r *mutationResolver) CreateUser(ctx context.Context, in CreateUserInput) (*CreateUserPayload, error) {\n    user, err := r.users.Create(ctx, in.Email, in.Name)\n    if err != nil {\n        return nil, presentError(err)\n    }\n    return &CreateUserPayload{User: toGQLUser(user)}, nil\n}\n\n// Bad — SQL inside the resolver.\nfunc (r *queryResolver) User(ctx context.Context, id string) (*User, error) {\n    row := r.db.QueryRowContext(ctx, \"SELECT * FROM users WHERE id = $1\", id)\n    // ...\n}\n```\n\nUse per-type resolver structs (`userResolver`, `postResolver`) instead of one monolithic resolver. It scales with the schema.\n\n## N+1 Prevention with DataLoaders\n\nA naive `User.posts` resolver fires one SQL query per user — O(n) round-trips. DataLoaders coalesce per-field loads within a single tick into one batched query.\n\n```go\n// Good — per-request DataLoader in middleware.\nfunc DataLoaderMiddleware(db *sql.DB, next http.Handler) http.Handler {\n    return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {\n        loaders := &Loaders{\n            PostsByUser: newPostsByUserLoader(r.Context(), db),\n        }\n        ctx := context.WithValue(r.Context(), loadersKey{}, loaders)\n        next.ServeHTTP(w, r.WithContext(ctx))\n    })\n}\n\n// Bad — package-level DataLoader caches across requests.\nvar globalLoader = newPostsByUserLoader(context.Background(), db)\n```\n\nPackage-level DataLoaders silently serve user A's data to user B's request as long as the cached key matches. This is the most dangerous bug in Go GraphQL services.\n\n## Authn vs Authz\n\nAuthenticate in HTTP middleware (extract identity, stash in `ctx`); authorize per-field via schema directives (`@hasRole(role: ADMIN)`) in gqlgen, or resolver-level checks in graph-gophers. Authorization policy belongs in the schema, not scattered across resolvers. See [references/gqlgen.md](references/gqlgen.md).\n\n## Error Handling\n\nNever surface raw `error` values — they leak SQL fragments and internals. Install an `ErrorPresenter` (gqlgen) or implement `ResolverError` (graph-gophers) that returns sanitized messages. Attach a stable `code` in extensions (`NOT_FOUND`, `FORBIDDEN`) for client handling. Use `graphql.AddError(ctx, err)` for non-fatal field errors with partial data.\n\n## Subscriptions\n\nEvery subscription goroutine must `defer close(ch)` and select on `ctx.Done()` in both the receive and send branches:\n\n```go\ngo func() {\n    defer close(ch)\n    for {\n        select {\n        case <-ctx.Done(): return\n        case msg := <-sub:\n            select { case ch <- msg: case <-ctx.Done(): return }\n        }\n    }\n}()\n```\n\nWithout this, every disconnected client leaks a goroutine.\n\n## Production Hardening\n\n- `extension.FixedComplexityLimit(200)` (gqlgen) or `graphql.MaxDepth(10)` + `MaxParallelism(10)` (graph-gophers)\n- Gate introspection behind an env check\n- Consider persisted queries (gqlgen APQ) so production only accepts pre-approved hashed queries\n\n## Anti-Patterns\n\n| Anti-pattern | Why it hurts | Do this instead |\n|---|---|---|\n| Package-level DataLoader | Cross-tenant data leakage, stale cache | Construct per-request in middleware |\n| SQL in resolver | Resolver becomes data layer; no batching | Delegate to service; load via DataLoader |\n| Non-null field that can fail | Cascade-nulls the parent | Make it nullable; or guarantee in resolver |\n| Editing `models_gen.go` | Wiped on next codegen | Use `autobind` / `models.<T>.model` in gqlgen.yml |\n| Introspection in production | Exposes full schema surface | Gate by env |\n| Subscription goroutine leak | Each disconnect leaks a goroutine | `defer close(ch)` + `select ctx.Done()` |\n| No complexity cap | Single deep query = CPU/memory DoS | `FixedComplexityLimit(N)` or persisted queries |\n| Raw internal error to client | Leaks DB messages, stack traces | `ErrorPresenter` returning sanitized message |\n| `int` field for `Int!` in graph-gophers | Library expects `int32` | Use `int32` (or `float64` for `Float`) |\n\n## Verification Checklist\n\n- [ ] Every non-null field is one the server can always return\n- [ ] List fields use cursor pagination, not offset\n- [ ] Mutations return envelope types with `errors: [UserError!]!`\n- [ ] DataLoaders are constructed in HTTP middleware, never package-level\n- [ ] Authentication is in HTTP middleware; authorization is in directives or resolver checks\n- [ ] `ErrorPresenter` (gqlgen) or `ResolverError` (graph-gophers) sanitizes internals\n- [ ] Every subscription `defer close(ch)` and selects on `ctx.Done()`\n- [ ] Complexity limit set; introspection gated by env\n- [ ] No resolver reads SQL directly\n\n## References\n\n- [references/gqlgen.md](references/gqlgen.md) — codegen workflow, `gqlgen.yml`, DataLoaders, Federation\n- [references/graph-gophers.md](references/graph-gophers.md) — reflection model, type mapping, tracing\n- [references/dataloaders.md](references/dataloaders.md) — batched loading patterns, cache lifecycle, gotchas\n- [references/anti-patterns.md](references/anti-patterns.md) — detailed walkthrough of each anti-pattern\n"
}

SHA-256 of public snapshot: 05941d5537e9f5c7f73f0962ca09d86c37ee675eba0ed109aa5fc225e4113b90