GraphQL lets API clients request exactly the fields they need, reducing over-fetching and enabling clients to aggregate data from multiple resources in a single request. In Go, gqlgen is the standard library — it generates type-safe resolver boilerplate from your schema, leaving you to implement the actual data fetching.
The key difference from REST: a GraphQL API has a single endpoint (/graphql), and the schema is the contract. Add a field to the schema and client tooling immediately knows it exists.
For REST comparison see Go REST vs gRPC and for REST implementation see Go building REST APIs.
Setup with gqlgen
go get github.com/99designs/gqlgen
go run github.com/99designs/gqlgen init
gqlgen init creates a schema.graphqls file and generates boilerplate. The workflow is schema-first: edit the schema, regenerate code, implement the generated interfaces.
Defining the Schema
# graph/schema.graphqls
type User {
id: ID!
name: String!
email: String!
posts: [Post!]!
createdAt: String!
}
type Post {
id: ID!
title: String!
content: String!
author: User!
published: Boolean!
}
type Query {
user(id: ID!): User
users(page: Int, perPage: Int): UserPage!
post(id: ID!): Post
}
type UserPage {
users: [User!]!
total: Int!
page: Int!
perPage: Int!
}
type Mutation {
createUser(input: CreateUserInput!): User!
updateUser(id: ID!, input: UpdateUserInput!): User!
deleteUser(id: ID!): Boolean!
createPost(input: CreatePostInput!): Post!
}
input CreateUserInput {
name: String!
email: String!
}
input UpdateUserInput {
name: String
email: String
}
input CreatePostInput {
title: String!
content: String!
authorID: ID!
}
type Subscription {
postCreated: Post!
}
After editing the schema, regenerate:
go run github.com/99designs/gqlgen generate
This regenerates graph/generated.go (resolver interfaces) and updates graph/model/models_gen.go (Go structs from schema types). You only implement the interfaces in graph/resolver.go.
Implementing Resolvers
gqlgen generates a Resolver interface for each type with fields that need custom resolution. You implement those interfaces:
// graph/resolver.go
package graph
type Resolver struct {
UserRepo UserRepository
PostRepo PostRepository
}
// graph/schema.resolvers.go — generated skeleton, you fill in the bodies
func (r *queryResolver) User(ctx context.Context, id string) (*model.User, error) {
user, err := r.UserRepo.GetByID(ctx, id)
if errors.Is(err, ErrNotFound) {
return nil, nil // GraphQL returns null for missing optional objects
}
return user, err
}
func (r *queryResolver) Users(ctx context.Context, page *int, perPage *int) (*model.UserPage, error) {
p, pp := 1, 20
if page != nil { p = *page }
if perPage != nil { pp = min(*perPage, 100) }
users, total, err := r.UserRepo.List(ctx, p, pp)
if err != nil {
return nil, err
}
return &model.UserPage{
Users: users,
Total: total,
Page: p,
PerPage: pp,
}, nil
}
func (r *mutationResolver) CreateUser(ctx context.Context, input model.CreateUserInput) (*model.User, error) {
// Validate
if strings.TrimSpace(input.Name) == "" {
return nil, fmt.Errorf("name is required")
}
if !strings.Contains(input.Email, "@") {
return nil, fmt.Errorf("invalid email")
}
return r.UserRepo.Create(ctx, input.Name, input.Email)
}
Field Resolvers for N+1 Prevention
The classic N+1 problem in GraphQL: a query for 20 users with their posts triggers 1 user query + 20 post queries. gqlgen’s field resolvers are where you hook in a DataLoader:
// graph/schema.resolvers.go
func (r *userResolver) Posts(ctx context.Context, obj *model.User) ([]*model.Post, error) {
// Without DataLoader: 1 DB query per user = N+1 problem
// return r.PostRepo.GetByUserID(ctx, obj.ID)
// With DataLoader: batched, 1 query for all users in this request
return PostLoaderFromContext(ctx).Load(obj.ID)
}
DataLoader batches all Posts field resolution calls within one request into a single database query. Install with:
go get github.com/graph-gophers/dataloader/v7
type PostLoader struct {
loader *dataloader.Loader[string, []*model.Post]
}
func NewPostLoader(repo PostRepository) *PostLoader {
batchFn := func(ctx context.Context, keys dataloader.Keys[string]) []*dataloader.Result[[]*model.Post] {
// Called once with all user IDs needed in this request
ids := keys.Keys()
postsByUser, err := repo.GetByUserIDs(ctx, ids)
results := make([]*dataloader.Result[[]*model.Post], len(ids))
for i, id := range ids {
if err != nil {
results[i] = &dataloader.Result[[]*model.Post]{Error: err}
} else {
results[i] = &dataloader.Result[[]*model.Post]{Data: postsByUser[id]}
}
}
return results
}
return &PostLoader{loader: dataloader.NewBatchedLoader(batchFn)}
}
func (l *PostLoader) Load(userID string) ([]*model.Post, error) {
thunk := l.loader.Load(context.Background(), userID)
return thunk()
}
Inject the DataLoader per request via context — it must be request-scoped, not shared across requests.
Middleware: Authentication
// Add middleware to the GraphQL server
srv := handler.NewDefaultServer(generated.NewExecutableSchema(generated.Config{Resolvers: &graph.Resolver{...}}))
// Authentication middleware
authMiddleware := func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
token := r.Header.Get("Authorization")
if strings.HasPrefix(token, "Bearer ") {
claims, err := parseJWT(strings.TrimPrefix(token, "Bearer "))
if err == nil {
ctx := context.WithValue(r.Context(), userCtxKey, claims)
r = r.WithContext(ctx)
}
}
next.ServeHTTP(w, r)
})
}
http.Handle("/graphql", authMiddleware(srv))
// In resolvers — check authentication
func currentUser(ctx context.Context) (*Claims, error) {
claims, ok := ctx.Value(userCtxKey).(*Claims)
if !ok {
return nil, fmt.Errorf("authentication required")
}
return claims, nil
}
func (r *mutationResolver) DeleteUser(ctx context.Context, id string) (bool, error) {
caller, err := currentUser(ctx)
if err != nil {
return false, err
}
if caller.Role != "admin" && caller.UserID != id {
return false, fmt.Errorf("forbidden")
}
return r.UserRepo.Delete(ctx, id)
}
Subscriptions
GraphQL subscriptions deliver real-time updates via WebSocket. gqlgen handles the WebSocket upgrade; you implement the event source:
func (r *subscriptionResolver) PostCreated(ctx context.Context) (<-chan *model.Post, error) {
ch := make(chan *model.Post, 10)
// Subscribe to your event bus, message queue, or DB notifications
sub, err := r.EventBus.Subscribe(ctx, "post.created")
if err != nil {
return nil, err
}
go func() {
defer close(ch)
defer sub.Unsubscribe()
for {
select {
case event := <-sub.Events():
post := event.Payload.(*model.Post)
select {
case ch <- post:
case <-ctx.Done():
return
}
case <-ctx.Done():
return
}
}
}()
return ch, nil
}
The client subscribes via WebSocket. gqlgen’s handler.NewDefaultServer includes WebSocket support automatically.
Error Handling
GraphQL returns partial data on errors — some fields can succeed while others fail. Return nil, err from a resolver to indicate that field failed:
func (r *queryResolver) User(ctx context.Context, id string) (*model.User, error) {
user, err := r.UserRepo.GetByID(ctx, id)
if errors.Is(err, ErrNotFound) {
return nil, nil // field is nullable — return null, no error
}
if err != nil {
// gqlgen wraps this in a GraphQL error object
return nil, fmt.Errorf("loading user: %w", err)
}
return user, nil
}
For user-visible error details (validation, authorization), use gqlgen’s gqlerror package to add extensions:
import "github.com/vektah/gqlparser/v2/gqlerror"
func (r *mutationResolver) CreateUser(ctx context.Context, input model.CreateUserInput) (*model.User, error) {
if input.Email == "" {
return nil, &gqlerror.Error{
Message: "email is required",
Extensions: map[string]interface{}{
"code": "VALIDATION_ERROR",
"field": "email",
},
}
}
// ...
}
Summary
- gqlgen is schema-first: edit
.graphqls, rungo generate, implement the generated interfaces - Field resolvers for nested types (
User.Posts) are where N+1 queries happen — use DataLoader to batch them - DataLoader must be request-scoped; inject via context in middleware
- Authentication middleware runs before the resolver; pull
Claimsfrom context inside resolvers - GraphQL subscriptions use WebSocket; gqlgen handles the protocol, you provide the event channel
- Return
nil, nilfor missing optional objects; usegqlerror.Errorwith extensions for user-visible validation errors
Comments