golang-graphql
GitHub用于在Go中实现GraphQL API,涵盖gqlgen或graphql-go库的使用。支持构建新Schema、编写Resolver、处理订阅及集成HTTP服务,并提供代码审查模式以检测N+1查询等性能问题。
Trigger Scenarios
Install
npx skills add samber/cc-skills-golang --skill golang-graphql -g -y
SKILL.md
Frontmatter
{
"name": "golang-graphql",
"paths": [
"**\/*.go"
],
"license": "MIT",
"metadata": {
"author": "samber",
"version": "0.2.2",
"openclaw": {
"emoji": "🔮",
"install": [],
"homepage": "https:\/\/github.com\/samber\/cc-skills-golang",
"requires": {
"bins": [
"go"
]
},
"skill-library-version": "0.17.89"
}
},
"description": "Implements GraphQL APIs in Golang using gqlgen or graphql-go. Apply when building GraphQL servers, designing schemas, writing resolvers, handling subscriptions, or integrating GraphQL with existing Go HTTP services. Also apply when the codebase imports `github.com\/99designs\/gqlgen` or `github.com\/graph-gophers\/graphql-go`.",
"allowed-tools": "Read Edit Write Glob Grep Bash(go:*) Bash(golangci-lint:*) Bash(git:*) Agent WebFetch mcp__context7__resolve-library-id mcp__context7__query-docs Bash(curl:*) Bash(godig:*) Bash(gopls:*) LSP mcp__gopls__*",
"compatibility": "Designed for Claude Code, Codex or similar harness, and for projects using Golang.",
"user-invocable": false
}
Persona: You are a Go GraphQL engineer. You design schemas deliberately, batch database access to prevent N+1, and treat query complexity limits as non-optional in production.
Modes:
- Build mode — generating new schemas, resolvers, or server setup: follow the skill's sequential instructions; launch a background agent to grep for existing resolver patterns and naming conventions before generating new code.
- Review mode — auditing a GraphQL codebase or PR: use a sub-agent to scan for N+1 resolver patterns, missing complexity caps, global DataLoaders, and introspection enabled in production, in parallel with reading the business logic.
Community default. A company skill that explicitly supersedes
samber/cc-skills-golang@golang-graphqlskill takes precedence.
Go GraphQL Best Practices
Both major libraries are schema-first: write SDL (.graphql files), bind Go resolvers. Choose based on project size and team preferences.
This skill is not exhaustive — refer to each library's official documentation and code examples for current API signatures:
- For Go package docs, symbols, versions, importers, and known vulnerabilities, → See
samber/cc-skills-golang@golang-pkg-go-devskill (godig), preferred over Context7 for Go package facts. - To navigate this library's usage in your own code (definitions, call sites, diagnostics), → See
samber/cc-skills-golang@golang-goplsskill (gopls). - Context7 remains a fallback for docs not indexed on pkg.go.dev.
Library Choice
| Library | Approach | Type safety | Build step | Best for |
|---|---|---|---|---|
github.com/99designs/gqlgen |
Codegen | Compile-time | go generate |
Large schemas, federation, strict types |
github.com/graph-gophers/graphql-go |
Reflection | Parse-time | None | Simple schemas, fast iteration |
github.com/graphql-go/graphql |
Code-first | Runtime | None | Avoid — verbose, no SDL |
Pick gqlgen when: Apollo Federation is required, schema is large (100+ types), or the team wants generated stubs and zero reflection overhead.
Pick graph-gophers when: schema is small/medium, the build pipeline should stay simple, or a dynamic schema is needed.
For deep-dive on each library, see gqlgen reference and graphql-go reference.
Schema Design
# ✓ Good — explicit nullability; ID scalar for opaque identifiers
type User {
id: ID!
email: String! # non-null: the server can always return this
bio: String # nullable: may be unset
posts(first: Int = 10, after: String): PostConnection!
}
# ✗ Bad — Int ID leaks implementation details, breaks client caching
type Post {
id: Int!
}
Nullability rule: mark a field ! only when the server can always return a value. A resolver error on a non-null field nulls the parent object, causing cascade failures; nullable fields only null the field itself.
Pagination: use Relay cursor connections (Connection/Edge/PageInfo) for list fields. Avoid offset pagination on large datasets — cursors are stable under concurrent writes.
Mutations: wrap results in an envelope type so clients receive business errors alongside partial results without polluting the GraphQL errors array:
type CreateUserPayload {
user: User
errors: [UserError!]!
}
Resolver Patterns
Keep resolvers thin — they translate GraphQL inputs to domain calls and domain responses to GraphQL outputs.
// ✓ Good — resolver delegates to service layer
func (r *mutationResolver) CreateUser(ctx context.Context, input model.CreateUserInput) (*model.CreateUserPayload, error) {
user, err := r.userService.Create(ctx, input.Email, input.Name)
if err != nil {
return nil, formatError(err)
}
return &model.CreateUserPayload{User: toGQLUser(user)}, nil
}
// ✗ Bad — SQL in resolver, no separation of concerns
func (r *queryResolver) User(ctx context.Context, id string) (*model.User, error) {
row := r.db.QueryRowContext(ctx, "SELECT * FROM users WHERE id = $1", id)
// ...
}
Use per-type resolver structs (userResolver, postResolver) rather than one monolithic resolver for all fields.
N+1 Prevention (DataLoaders)
Each User.posts resolver fires a SQL query per user without batching — O(n) DB calls for n users. DataLoaders solve this by coalescing per-field loads into a single batch query.
Critical rule: DataLoaders MUST be created per-request in HTTP middleware, never globally. A global DataLoader caches across requests — stale data, potential cross-user data leakage.
// ✓ Good — per-request DataLoader in middleware
func DataLoaderMiddleware(db *sql.DB, next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
loaders := &Loaders{
PostsByUserID: newPostsByUserIDLoader(r.Context(), db),
}
ctx := context.WithValue(r.Context(), loadersKey, loaders)
next.ServeHTTP(w, r.WithContext(ctx))
})
}
// ✗ Bad — global DataLoader shared across all requests
var globalLoader = newPostsByUserIDLoader(context.Background(), db)
In gqlgen, mark batched fields with resolver: true in gqlgen.yml to force a dedicated resolver method. See gqlgen reference for full DataLoader wiring.
Authentication and Authorization
Two-layer model:
- HTTP middleware — extract and validate tokens, stash identity in
context.Context. - Schema directives (gqlgen) or resolver checks (graphql-go) — enforce per-field authorization.
// HTTP middleware layer (both libraries)
func AuthMiddleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
token := r.Header.Get("Authorization")
user, err := validateToken(token)
if err != nil {
http.Error(w, "Unauthorized", http.StatusUnauthorized)
return
}
ctx := context.WithValue(r.Context(), userKey, user)
next.ServeHTTP(w, r.WithContext(ctx))
})
}
In gqlgen, use @hasRole schema directives for field-level authorization — authorization policy lives in the schema, not scattered across resolvers. See gqlgen reference.
Error Handling
Never return raw internal errors — they leak SQL messages, stack traces, or service internals to clients.
// gqlgen — custom ErrorPresenter strips internal details
srv.SetErrorPresenter(func(ctx context.Context, err error) *gqlerror.Error {
var gqlErr *gqlerror.Error
if errors.As(err, &gqlErr) {
return gqlErr // already formatted
}
// log internal err here
return gqlerror.Errorf("internal error") // safe client message
})
// Add extension codes for client-side error handling
return nil, &gqlerror.Error{
Message: "user not found",
Extensions: map[string]any{"code": "NOT_FOUND"},
}
For graph-gophers, implement the ResolverError interface to attach Extensions(). See graphql-go reference.
Use graphql.AddError(ctx, err) in gqlgen for non-fatal field errors where the resolver can still return partial data.
For error wrapping patterns, see the samber/cc-skills-golang@golang-error-handling skill.
Subscriptions
Subscriptions use long-lived WebSocket connections. The critical discipline: always respect context cancellation — a leaked goroutine per disconnected client exhausts resources silently.
// ✓ Good — closes channel when client disconnects
func (r *subscriptionResolver) MessageAdded(ctx context.Context, room string) (<-chan *model.Message, error) {
ch := make(chan *model.Message, 1)
sub := r.pubsub.Subscribe(room) // subscribe once before the goroutine
go func() {
defer close(ch) // always close; signals iteration to stop
for {
select {
case <-ctx.Done():
return // client disconnected
case msg := <-sub:
select {
case ch <- msg:
case <-ctx.Done():
return
}
}
}
}()
return ch, nil
}
// ✗ Bad — goroutine leaks forever when client disconnects
func (r *subscriptionResolver) MessageAdded(ctx context.Context, room string) (<-chan *model.Message, error) {
ch := make(chan *model.Message, 1)
go func() {
for msg := range r.pubsub.Subscribe(room) {
ch <- msg // blocks forever after client gone
}
}()
return ch, nil
}
Performance and Safety
Production GraphQL servers require explicit limits. Without them, a single deeply nested query exhausts CPU and memory.
// gqlgen — wire these into every production handler
srv := handler.NewDefaultServer(es)
srv.Use(extension.FixedComplexityLimit(200)) // max cost per query
// Gate introspection — only in non-production environments
if os.Getenv("ENV") != "production" {
srv.Use(extension.Introspection{})
}
For graph-gophers: graphql.MaxDepth(10) and graphql.MaxParallelism(10) options at ParseSchema time.
Query allow-listing: in production, consider persisted queries (gqlgen APQ extension) to reject arbitrary query strings.
Common Mistakes
| Mistake | Why it matters | Fix |
|---|---|---|
| N+1 queries in child resolvers | One SQL per parent row → O(n) DB calls | Use per-request DataLoader |
| Global DataLoader | Cross-request cache — stale data, data leaks | Create DataLoader in request middleware |
Editing models_gen.go directly |
Next go generate wipes hand edits |
Use autobind or models.<T>.model in gqlgen.yml |
Forgetting go generate after schema change |
Resolver interface mismatch at compile time | Re-run go tool gqlgen generate |
int field in graph-gophers resolver |
Library requires int32 for Int scalar |
Use int32 (or float64 for Float) |
| Introspection enabled in production | Exposes full schema to attackers | Gate with ENV check |
| No complexity cap | Deeply nested query → CPU/memory DoS | extension.FixedComplexityLimit(N) |
| Leaking DB errors from resolvers | Exposes SQL internals to clients | Wrap in ErrorPresenter / ResolverError |
| Subscription goroutine leak | Client disconnect → goroutine runs forever | defer close(ch) + select ctx.Done() |
| Nullable field for always-required data | Clients must null-check everywhere | Mark ! in schema; return error from resolver |
Deep Dives
- gqlgen reference — codegen workflow,
gqlgen.yml, DataLoaders, Federation v2, directives - graphql-go reference — reflection resolver model, type mapping, tracing
- Testing — gqlgen client harness, gqltesting, httptest patterns
Cross-References
- → See
samber/cc-skills-golang@golang-contextskill for context propagation in resolvers and subscriptions - → See
samber/cc-skills-golang@golang-error-handlingskill for error wrapping and sentinel patterns - → See
samber/cc-skills-golang@golang-testingskill for table-driven and integration test patterns - → See
samber/cc-skills-golang@golang-observabilityskill for tracing and metrics in resolvers - → See
samber/cc-skills-golang@golang-securityskill for input validation and injection prevention - → See
samber/cc-skills-golang@golang-databaseskill for N+1 query patterns and DataLoader database batching
References
If you encounter a bug or unexpected behavior in gqlgen, open an issue at https://github.com/99designs/gqlgen/issues.
If you encounter a bug or unexpected behavior in graph-gophers/graphql-go, open an issue at https://github.com/graph-gophers/graphql-go/issues.
Version History
-
bac46b0
Current 2026-09-03 09:50
优化文档表述,规范段落长度并澄清Token预算说明;移除不再使用的文本质检脚本,保留相关约束规则。
-
147c067
2026-08-28 11:53
无内容变更,仅进行版本升级和工具链适配说明。
- 709b181 2026-07-25 07:35


