7.3 KiB
gqlgen Reference
gqlgen is a schema-first, code-generation library. Write SDL, run go generate, fill in resolver bodies.
Project Setup
# Bootstrap a new project
go run github.com/99designs/gqlgen init
# Pin the tool in go.mod for reproducible generation (Go 1.24+)
go get -tool github.com/99designs/gqlgen@latest
For Go <1.24 modules, use the legacy tools.go blank-import workaround instead.
# Regenerate after every schema change
go tool gqlgen generate
Never hand-edit generated files (generated.go, models_gen.go) — generate overwrites them.
gqlgen.yml
schema:
- graph/schema/*.graphql
exec:
filename: graph/generated.go
package: graph
model:
filename: graph/model/models_gen.go
package: model
resolver:
layout: follow-schema # one resolvers file per schema file
dir: graph
package: graph
filename_template: "{name}.resolvers.go"
autobind:
- github.com/me/app/internal/domain # reuse existing structs
models:
# ID: graphql.IntID # legacy only — use opaque string IDs for new schemas
User:
model: github.com/me/app/internal/domain.User
fields:
posts:
resolver: true # force a custom resolver (required for DataLoader fields)
omit_slice_element_pointers: true
struct_fields_always_pointers: false
resolvers_always_return_pointers: true
Key knobs:
autobind— maps Go structs to GraphQL types; fields must match by name (case-insensitive)models.<T>.model— override which Go type backs a GraphQL typefields.<f>.resolver: true— force a custom resolver instead of struct field access; required for any field that should batch via DataLoaderstruct_fields_always_pointers/resolvers_always_return_pointers— controls*TvsTin generated signatures; match your domain model conventions
Resolver Structure
The generated Config holds a Resolvers field of the generated interface. You implement it:
// graph/resolver.go — you own this file, not generated
type Resolver struct {
db *sql.DB
userService *service.UserService
loaders *dataloaders.Loaders // injected per-request
}
Per-type resolvers implement the generated interface split by GraphQL type:
type queryResolver struct{ *Resolver }
type mutationResolver struct{ *Resolver }
type userResolver struct{ *Resolver }
func (r *queryResolver) User(ctx context.Context, id string) (*model.User, error) { ... }
func (r *userResolver) Posts(ctx context.Context, obj *model.User) ([]*model.Post, error) { ... }
obj is the parent object — the entry point for walking the graph.
DataLoaders (gqlgen)
Use github.com/vikstrous/dataloadgen (generics, fast) or github.com/graph-gophers/dataloader:
// Inject per-request via middleware
func Middleware(db *sql.DB, next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
loaders := &Loaders{
PostsByUserID: dataloadgen.NewLoader(func(ctx context.Context, ids []string) ([][]*domain.Post, []error) {
return batchPostsByUserID(ctx, db, ids) // returns one []Post per user ID
}, dataloadgen.WithWait(1*time.Millisecond)),
}
ctx := context.WithValue(r.Context(), loadersKey, loaders)
next.ServeHTTP(w, r.WithContext(ctx))
})
}
// Resolver uses the loader — never the DB directly
func (r *userResolver) Posts(ctx context.Context, obj *model.User) ([]*model.Post, error) {
return loaders.For(ctx).PostsByUserID.Load(ctx, obj.ID)
}
Set wait to 1–2ms — allows multiple concurrent resolvers to register keys before the batch fires.
Authentication Directives
directive @hasRole(role: Role!) on FIELD_DEFINITION
type Query {
adminStats: Stats! @hasRole(role: ADMIN)
}
// Implement the directive function
func HasRole(ctx context.Context, obj any, next graphql.Resolver, role model.Role) (any, error) {
user := auth.UserFromContext(ctx)
if user == nil || user.Role != role {
return nil, &gqlerror.Error{
Message: "access denied",
Extensions: map[string]any{"code": "FORBIDDEN"},
}
}
return next(ctx)
}
// Register at server bootstrap
c := generated.Config{
Resolvers: &graph.Resolver{...},
Directives: generated.DirectiveRoot{
HasRole: HasRole,
},
}
Middleware Hooks
srv.AroundOperations(func(ctx context.Context, next graphql.OperationHandler) graphql.ResponseHandler {
// log operation name, add trace span
return next(ctx)
})
srv.AroundFields(func(ctx context.Context, next graphql.Resolver) (any, error) {
// per-field tracing, timing
return next(ctx)
})
Error Presenter
srv.SetErrorPresenter(func(ctx context.Context, err error) *gqlerror.Error {
var gqlErr *gqlerror.Error
if errors.As(err, &gqlErr) {
return gqlErr
}
log.Ctx(ctx).Error("resolver error", "err", err)
return gqlerror.Errorf("internal server error")
})
srv.SetRecoverFunc(func(ctx context.Context, err any) error {
log.Ctx(ctx).Error("panic in resolver", "err", err)
return fmt.Errorf("internal server error")
})
Subscriptions
srv.AddTransport(transport.Websocket{
KeepAlivePingInterval: 10 * time.Second,
Upgrader: websocket.Upgrader{
// Restrict to your own origin in production; true here is dev-only.
CheckOrigin: func(r *http.Request) bool {
return r.Header.Get("Origin") == "https://app.example.com"
},
},
InitFunc: func(ctx context.Context, initPayload transport.InitPayload) (context.Context, *transport.InitPayload, error) {
// auth at connection time
token := initPayload.Authorization()
user, err := validateToken(token)
if err != nil {
return ctx, nil, err
}
return context.WithValue(ctx, userKey, user), &initPayload, nil
},
})
gqlgen supports both graphql-ws (legacy) and graphql-transport-ws (current) subprotocols.
File Uploads
srv.AddTransport(transport.MultipartForm{
MaxUploadSize: 10 << 20, // 10 MB total
MaxMemory: 5 << 20, // 5 MB in memory; rest spills to disk
})
Schema:
scalar Upload
type Mutation {
uploadAvatar(file: Upload!): User!
}
Resolver receives graphql.Upload{File io.Reader, Filename string, Size int64, ContentType string}.
Apollo Federation v2
gqlgen.yml:
federation:
filename: graph/federation.go
version: 2
Schema:
extend schema
@link(
url: "https://specs.apollo.dev/federation/v2.3"
import: ["@key", "@shareable", "@external"]
)
type User @key(fields: "id") {
id: ID!
name: String!
}
Implement FindUserByID in the generated entity resolver. Works with Apollo Router and Cosmo.
Production Handler Setup
srv := handler.New(es)
srv.AddTransport(transport.Options{})
srv.AddTransport(transport.GET{})
srv.AddTransport(transport.POST{})
srv.AddTransport(transport.MultipartForm{MaxUploadSize: 10 << 20, MaxMemory: 5 << 20})
srv.AddTransport(transport.Websocket{KeepAlivePingInterval: 10 * time.Second})
srv.SetQueryCache(lru.New[*ast.QueryDocument](1000))
if os.Getenv("ENV") != "production" {
srv.Use(extension.Introspection{})
}
srv.Use(extension.AutomaticPersistedQuery{Cache: lru.New[string](100)})
srv.Use(extension.FixedComplexityLimit(200))