GraphQL en Go con gqlgen: schema-first, resolvers, mutations y dataloaders

gqlgen es el generador de servidores GraphQL en Go más popular y su enfoque es schema-first: defines el esquema en SDL y gqlgen genera las interfaces Go que tienes que implementar. Esto garantiza que la API y el código de Go nunca se desincronicen.

Inicialización con gqlgen

go get github.com/99designs/gqlgen
go run github.com/99designs/gqlgen init

El comando init crea la estructura básica: graph/schema.graphqls, graph/resolver.go, gqlgen.yml y server.go. Cada vez que modifiques el schema ejecuta go run github.com/99designs/gqlgen generate para regenerar el código.

Definir el schema

type User {
  id:       ID!
  name:     String!
  email:    String!
  posts:    [Post!]!
}

type Post {
  id:      ID!
  title:   String!
  body:    String!
  author:  User!
}

type Query {
  user(id: ID!): User
  posts: [Post!]!
}

type Mutation {
  createUser(name: String!, email: String!): User!
  createPost(authorId: ID!, title: String!, body: String!): Post!
}

type Subscription {
  postCreated: Post!
}

Implementar los resolvers

gqlgen genera una interfaz por cada tipo con campos resolubles. La implementas en graph/resolver.go:

type Resolver struct {
    users map[string]*model.User
    posts map[string]*model.Post
    mu    sync.RWMutex
    postSubs []chan *model.Post
}

func (r *queryResolver) User(ctx context.Context, id string) (*model.User, error) {
    r.mu.RLock()
    defer r.mu.RUnlock()
    u, ok := r.users[id]
    if !ok {
        return nil, fmt.Errorf("usuario %s no encontrado", id)
    }
    return u, nil
}

func (r *queryResolver) Posts(ctx context.Context) ([]*model.Post, error) {
    r.mu.RLock()
    defer r.mu.RUnlock()
    posts := make([]*model.Post, 0, len(r.posts))
    for _, p := range r.posts {
        posts = append(posts, p)
    }
    return posts, nil
}

Mutations

func (r *mutationResolver) CreateUser(ctx context.Context, name, email string) (*model.User, error) {
    r.mu.Lock()
    defer r.mu.Unlock()
    u := &model.User{
        ID:    fmt.Sprintf("u%d", len(r.users)+1),
        Name:  name,
        Email: email,
    }
    r.users[u.ID] = u
    return u, nil
}

func (r *mutationResolver) CreatePost(ctx context.Context, authorID, title, body string) (*model.Post, error) {
    r.mu.Lock()
    defer r.mu.Unlock()
    p := &model.Post{
        ID:     fmt.Sprintf("p%d", len(r.posts)+1),
        Title:  title,
        Body:   body,
        Author: r.users[authorID],
    }
    r.posts[p.ID] = p
    // notificar subscripciones
    go func() {
        for _, ch := range r.postSubs {
            ch <- p
        }
    }()
    return p, nil
}

El problema N+1 y dataloaders

Cuando GraphQL resuelve una lista de posts y para cada uno carga el autor por separado, ejecuta N+1 queries a la base de datos. El dataloader agrupa esas peticiones individuales en una sola query:

// go get github.com/graph-gophers/dataloader/v7
type Loaders struct {
    UserByID *dataloader.Loader[string, *model.User]
}

func NewLoaders(db *sql.DB) *Loaders {
    return &Loaders{
        UserByID: dataloader.NewBatchedLoader(func(ctx context.Context, ids []string) []*dataloader.Result[*model.User] {
            // una sola query para todos los ids
            users := cargarUsuariosPorIds(db, ids)
            results := make([]*dataloader.Result[*model.User], len(ids))
            for i, id := range ids {
                if u, ok := users[id]; ok {
                    results[i] = &dataloader.Result[*model.User]{Data: u}
                } else {
                    results[i] = &dataloader.Result[*model.User]{Error: fmt.Errorf("user %s not found", id)}
                }
            }
            return results
        }),
    }
}

// En el resolver de Post.Author:
func (r *postResolver) Author(ctx context.Context, obj *model.Post) (*model.User, error) {
    return loaders.UserByID.Load(ctx, obj.AuthorID)()
}

Subscriptions en tiempo real

func (r *subscriptionResolver) PostCreated(ctx context.Context) (<-chan *model.Post, error) {
    ch := make(chan *model.Post, 1)
    r.mu.Lock()
    r.postSubs = append(r.postSubs, ch)
    r.mu.Unlock()

    go func() {
        <-ctx.Done() // cliente desconectado
        r.mu.Lock()
        for i, sub := range r.postSubs {
            if sub == ch {
                r.postSubs = append(r.postSubs[:i], r.postSubs[i+1:]...)
                break
            }
        }
        r.mu.Unlock()
        close(ch)
    }()

    return ch, nil
}

gqlgen usa WebSockets o Server-Sent Events para las subscriptions. El servidor HTTP de server.go generado ya configura el handler correcto si incluyes el handler de subscriptions en el playground.

COMPARTE ESTE ARTÍCULO

COMPARTIR EN FACEBOOK
COMPARTIR EN TWITTER
COMPARTIR EN LINKEDIN
COMPARTIR EN WHATSAPP