Files
teamai-test/.teamai/skills/common/golang-graphql/references/gqlgen.md
T

7.3 KiB
Raw Blame History

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 type
  • fields.<f>.resolver: true — force a custom resolver instead of struct field access; required for any field that should batch via DataLoader
  • struct_fields_always_pointers / resolvers_always_return_pointers — controls *T vs T in 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))