Go GraphQL: Criando APIs com gqlgen

GraphQL revolucionou a forma como construímos APIs, oferecendo uma alternativa flexível ao REST tradicional. Com Go e gqlgen, você pode criar APIs GraphQL de alto desempenho com type-safety e excelente experiência de desenvolvimento.

Neste guia completo, você vai aprender a construir uma API GraphQL do zero usando gqlgen, o framework mais popular para GraphQL em Go.

O Que Você Vai Aprender

  • Diferenças entre GraphQL e REST
  • Configuração do gqlgen
  • Definição de schema GraphQL
  • Implementação de resolvers
  • Mutations e queries
  • Subscriptions em tempo real
  • Testes de APIs GraphQL

GraphQL vs REST: Por Que Migrar?

Problemas do REST

GET /users/1
GET /users/1/posts
GET /users/1/posts/123/comments

No REST, você frequentemente precisa de múltiplas requisições para obter dados relacionados:

  • Over-fetching: Recebe mais dados do que precisa
  • Under-fetching: Precisa de múltiplas requisições
  • Versionamento: APIs v1, v2, v3 criam complexidade

Vantagens do GraphQL

query {
  user(id: 1) {
    name
    email
    posts {
      title
      comments {
        text
      }
    }
  }
}

Com GraphQL, você obtém exatamente o que precisa em uma única requisição:

CaracterísticaRESTGraphQL
Obter dados relacionadosMúltiplas requisiçõesUma requisição
Over-fetchingComumImpossível
VersionamentoNecessárioDesnecessário
IntrospectionLimitadaCompleta
Type SafetyManualNativa

Configuração do Projeto

1. Inicialize o Módulo Go

mkdir go-graphql-api
cd go-graphql-api
go mod init github.com/seuusuario/go-graphql-api

2. Instale o gqlgen

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

Isso cria a estrutura inicial:

.
├── gqlgen.yml          # Configuração do gqlgen
├── graph/
│   ├── schema.graphqls  # Schema GraphQL
│   ├── resolver.go      # Implementação dos resolvers
│   ├── generated.go     # Código gerado (não edite)
│   └── model/
│       └── models_gen.go # Modelos gerados
└── server.go            # Entry point

Definindo o Schema GraphQL

Schema Completo para API de Blog

Edite graph/schema.graphqls:

# Tipos enumerados
enum PostStatus {
  DRAFT
  PUBLISHED
  ARCHIVED
}

# Tipos de dados
type User {
  id: ID!
  name: String!
  email: String!
  posts: [Post!]!
  createdAt: String!
}

type Post {
  id: ID!
  title: String!
  content: String!
  status: PostStatus!
  author: User!
  comments: [Comment!]!
  createdAt: String!
  updatedAt: String!
}

type Comment {
  id: ID!
  text: String!
  author: User!
  post: Post!
  createdAt: String!
}

# Input types para mutations
input CreateUserInput {
  name: String!
  email: String!
}

input CreatePostInput {
  title: String!
  content: String!
  authorId: ID!
  status: PostStatus
}

input CreateCommentInput {
  text: String!
  postId: ID!
  authorId: ID!
}

input UpdatePostInput {
  id: ID!
  title: String
  content: String
  status: PostStatus
}

# Queries
type Query {
  # Users
  users: [User!]!
  user(id: ID!): User
  
  # Posts
  posts(status: PostStatus): [Post!]!
  post(id: ID!): Post
  
  # Comments
  comments(postId: ID!): [Comment!]!
}

# Mutations
type Mutation {
  # Users
  createUser(input: CreateUserInput!): User!
  
  # Posts
  createPost(input: CreatePostInput!): Post!
  updatePost(input: UpdatePostInput!): Post!
  deletePost(id: ID!): Boolean!
  
  # Comments
  createComment(input: CreateCommentInput!): Comment!
  deleteComment(id: ID!): Boolean!
}

# Subscriptions
type Subscription {
  commentAdded(postId: ID!): Comment!
  postPublished: Post!
}

Gere o Código

Após definir o schema:

go run github.com/99designs/gqlgen generate

Isso gera automaticamente:

  • Interfaces para resolvers
  • Modelos Go a partir dos tipos GraphQL
  • Código de execução da query

Implementando Resolvers

Estrutura Base

Crie graph/resolver.go:

package graph

import (
    "sync"
    "github.com/seuusuario/go-graphql-api/graph/model"
)

type Resolver struct {
    users     map[string]*model.User
    posts     map[string]*model.Post
    comments  map[string]*model.Comment
    mu        sync.RWMutex
    
    // Para subscriptions
    commentObservers map[string][]chan *model.Comment
    postObservers    []chan *model.Post
}

func NewResolver() *Resolver {
    return &Resolver{
        users:            make(map[string]*model.User),
        posts:            make(map[string]*model.Post),
        comments:         make(map[string]*model.Comment),
        commentObservers: make(map[string][]chan *model.Comment),
        postObservers:    make([]chan *model.Post),
    }
}

Query Resolvers

Crie graph/schema.resolvers.go:

package graph

import (
    "context"
    "fmt"
    "time"
    
    "github.com/google/uuid"
    "github.com/seuusuario/go-graphql-api/graph/model"
)

// Users Query
func (r *queryResolver) Users(ctx context.Context) ([]*model.User, error) {
    r.mu.RLock()
    defer r.mu.RUnlock()
    
    users := make([]*model.User, 0, len(r.users))
    for _, u := range r.users {
        users = append(users, u)
    }
    return users, nil
}

func (r *queryResolver) User(ctx context.Context, id string) (*model.User, error) {
    r.mu.RLock()
    defer r.mu.RUnlock()
    
    user, ok := r.users[id]
    if !ok {
        return nil, fmt.Errorf("usuário não encontrado: %s", id)
    }
    return user, nil
}

// Posts Query
func (r *queryResolver) Posts(ctx context.Context, status *model.PostStatus) ([]*model.Post, error) {
    r.mu.RLock()
    defer r.mu.RUnlock()
    
    posts := []*model.Post{}
    for _, p := range r.posts {
        if status == nil || p.Status == *status {
            posts = append(posts, p)
        }
    }
    return posts, nil
}

func (r *queryResolver) Post(ctx context.Context, id string) (*model.Post, error) {
    r.mu.RLock()
    defer r.mu.RUnlock()
    
    post, ok := r.posts[id]
    if !ok {
        return nil, fmt.Errorf("post não encontrado: %s", id)
    }
    return post, nil
}

// Comments Query
func (r *queryResolver) Comments(ctx context.Context, postID string) ([]*model.Comment, error) {
    r.mu.RLock()
    defer r.mu.RUnlock()
    
    comments := []*model.Comment{}
    for _, c := range r.comments {
        if c.PostID == postID {
            comments = append(comments, c)
        }
    }
    return comments, nil
}

Field Resolvers

Implemente resolvers para campos complexos:

// Resolver para Post.Author
func (r *postResolver) Author(ctx context.Context, obj *model.Post) (*model.User, error) {
    r.mu.RLock()
    defer r.mu.RUnlock()
    
    user, ok := r.users[obj.AuthorID]
    if !ok {
        return nil, fmt.Errorf("autor não encontrado: %s", obj.AuthorID)
    }
    return user, nil
}

// Resolver para Post.Comments
func (r *postResolver) Comments(ctx context.Context, obj *model.Post) ([]*model.Comment, error) {
    r.mu.RLock()
    defer r.mu.RUnlock()
    
    comments := []*model.Comment{}
    for _, c := range r.comments {
        if c.PostID == obj.ID {
            comments = append(comments, c)
        }
    }
    return comments, nil
}

// Resolver para User.Posts
func (r *userResolver) Posts(ctx context.Context, obj *model.User) ([]*model.Post, error) {
    r.mu.RLock()
    defer r.mu.RUnlock()
    
    posts := []*model.Post{}
    for _, p := range r.posts {
        if p.AuthorID == obj.ID {
            posts = append(posts, p)
        }
    }
    return posts, nil
}

Implementando Mutations

Create Operations

// CreateUser Mutation
func (r *mutationResolver) CreateUser(ctx context.Context, input model.CreateUserInput) (*model.User, error) {
    r.mu.Lock()
    defer r.mu.Unlock()
    
    // Validação de email único
    for _, u := range r.users {
        if u.Email == input.Email {
            return nil, fmt.Errorf("email já cadastrado: %s", input.Email)
        }
    }
    
    user := &model.User{
        ID:        uuid.New().String(),
        Name:      input.Name,
        Email:     input.Email,
        CreatedAt: time.Now().Format(time.RFC3339),
    }
    
    r.users[user.ID] = user
    return user, nil
}

// CreatePost Mutation
func (r *mutationResolver) CreatePost(ctx context.Context, input model.CreatePostInput) (*model.Post, error) {
    r.mu.Lock()
    defer r.mu.Unlock()
    
    // Verifica se autor existe
    if _, ok := r.users[input.AuthorID]; !ok {
        return nil, fmt.Errorf("autor não encontrado: %s", input.AuthorID)
    }
    
    status := model.PostStatusDraft
    if input.Status != nil {
        status = *input.Status
    }
    
    now := time.Now().Format(time.RFC3339)
    post := &model.Post{
        ID:        uuid.New().String(),
        Title:     input.Title,
        Content:   input.Content,
        AuthorID:  input.AuthorID,
        Status:    status,
        CreatedAt: now,
        UpdatedAt: now,
    }
    
    r.posts[post.ID] = post
    
    // Notifica subscribers se está publicado
    if status == model.PostStatusPublished {
        r.notifyPostPublished(post)
    }
    
    return post, nil
}

// CreateComment Mutation
func (r *mutationResolver) CreateComment(ctx context.Context, input model.CreateCommentInput) (*model.Comment, error) {
    r.mu.Lock()
    defer r.mu.Unlock()
    
    // Validações
    if _, ok := r.users[input.AuthorID]; !ok {
        return nil, fmt.Errorf("autor não encontrado: %s", input.AuthorID)
    }
    if _, ok := r.posts[input.PostID]; !ok {
        return nil, fmt.Errorf("post não encontrado: %s", input.PostID)
    }
    
    comment := &model.Comment{
        ID:        uuid.New().String(),
        Text:      input.Text,
        AuthorID:  input.AuthorID,
        PostID:    input.PostID,
        CreatedAt: time.Now().Format(time.RFC3339),
    }
    
    r.comments[comment.ID] = comment
    
    // Notifica subscribers
    r.notifyCommentAdded(input.PostID, comment)
    
    return comment, nil
}

Update e Delete Operations

// UpdatePost Mutation
func (r *mutationResolver) UpdatePost(ctx context.Context, input model.UpdatePostInput) (*model.Post, error) {
    r.mu.Lock()
    defer r.mu.Unlock()
    
    post, ok := r.posts[input.ID]
    if !ok {
        return nil, fmt.Errorf("post não encontrado: %s", input.ID)
    }
    
    oldStatus := post.Status
    
    if input.Title != nil {
        post.Title = *input.Title
    }
    if input.Content != nil {
        post.Content = *input.Content
    }
    if input.Status != nil {
        post.Status = *input.Status
    }
    
    post.UpdatedAt = time.Now().Format(time.RFC3339)
    
    // Notifica se foi publicado
    if oldStatus != model.PostStatusPublished && post.Status == model.PostStatusPublished {
        r.notifyPostPublished(post)
    }
    
    return post, nil
}

// DeletePost Mutation
func (r *mutationResolver) DeletePost(ctx context.Context, id string) (bool, error) {
    r.mu.Lock()
    defer r.mu.Unlock()
    
    if _, ok := r.posts[id]; !ok {
        return false, fmt.Errorf("post não encontrado: %s", id)
    }
    
    delete(r.posts, id)
    
    // Remove comentários associados
    for commentID, c := range r.comments {
        if c.PostID == id {
            delete(r.comments, commentID)
        }
    }
    
    return true, nil
}

// DeleteComment Mutation
func (r *mutationResolver) DeleteComment(ctx context.Context, id string) (bool, error) {
    r.mu.Lock()
    defer r.mu.Unlock()
    
    if _, ok := r.comments[id]; !ok {
        return false, fmt.Errorf("comentário não encontrado: %s", id)
    }
    
    delete(r.comments, id)
    return true, nil
}

Subscriptions em Tempo Real

Configurando Subscriptions

// Subscription: CommentAdded
func (r *subscriptionResolver) CommentAdded(ctx context.Context, postID string) (<-chan *model.Comment, error) {
    ch := make(chan *model.Comment, 1)
    
    r.mu.Lock()
    r.commentObservers[postID] = append(r.commentObservers[postID], ch)
    r.mu.Unlock()
    
    // Cleanup quando contexto é cancelado
    go func() {
        <-ctx.Done()
        r.mu.Lock()
        defer r.mu.Unlock()
        
        observers := r.commentObservers[postID]
        for i, observer := range observers {
            if observer == ch {
                r.commentObservers[postID] = append(observers[:i], observers[i+1:]...)
                break
            }
        }
        close(ch)
    }()
    
    return ch, nil
}

// Subscription: PostPublished
func (r *subscriptionResolver) PostPublished(ctx context.Context) (<-chan *model.Post, error) {
    ch := make(chan *model.Post, 1)
    
    r.mu.Lock()
    r.postObservers = append(r.postObservers, ch)
    r.mu.Unlock()
    
    go func() {
        <-ctx.Done()
        r.mu.Lock()
        defer r.mu.Unlock()
        
        for i, observer := range r.postObservers {
            if observer == ch {
                r.postObservers = append(r.postObservers[:i], r.postObservers[i+1:]...)
                break
            }
        }
        close(ch)
    }()
    
    return ch, nil
}

// Métodos auxiliares de notificação
func (r *Resolver) notifyCommentAdded(postID string, comment *model.Comment) {
    r.mu.RLock()
    observers := r.commentObservers[postID]
    r.mu.RUnlock()
    
    for _, ch := range observers {
        select {
        case ch <- comment:
        default:
            // Channel cheio, ignora
        }
    }
}

func (r *Resolver) notifyPostPublished(post *model.Post) {
    r.mu.RLock()
    observers := r.postObservers
    r.mu.RUnlock()
    
    for _, ch := range observers {
        select {
        case ch <- post:
        default:
            // Channel cheio, ignora
        }
    }
}

Configurando o Servidor

Server.go

package main

import (
    "log"
    "net/http"
    "time"
    
    "github.com/99designs/gqlgen/graphql/handler"
    "github.com/99designs/gqlgen/graphql/handler/extension"
    "github.com/99designs/gqlgen/graphql/handler/transport"
    "github.com/99designs/gqlgen/graphql/playground"
    "github.com/go-chi/chi"
    "github.com/gorilla/websocket"
    "github.com/rs/cors"
    
    "github.com/seuusuario/go-graphql-api/graph"
)

func main() {
    router := chi.NewRouter()
    
    // Configura CORS
    router.Use(cors.New(cors.Options{
        AllowedOrigins:   []string{"*"},
        AllowCredentials: true,
        AllowedMethods:   []string{"GET", "POST", "OPTIONS"},
        AllowedHeaders:   []string{"*"},
    }).Handler)
    
    // Cria resolver
    resolver := graph.NewResolver()
    
    // Configura servidor GraphQL
    srv := handler.NewDefaultServer(graph.NewExecutableSchema(graph.Config{
        Resolvers: resolver,
    }))
    
    // Configurações adicionais
    srv.AddTransport(&transport.Websocket{
        Upgrader: websocket.Upgrader{
            CheckOrigin: func(r *http.Request) bool {
                return true
            },
            ReadBufferSize:  1024,
            WriteBufferSize: 1024,
        },
        KeepAlivePingInterval: 10 * time.Second,
    })
    
    srv.AddTransport(transport.Options{})
    srv.AddTransport(transport.GET{})
    srv.AddTransport(transport.POST{})
    srv.AddTransport(transport.MultipartForm{})
    
    srv.Use(extension.Introspection{})
    srv.Use(extension.AutomaticPersistedQuery{
        Cache: lru.New(100),
    })
    
    // Rotas
    router.Handle("/", playground.Handler("GraphQL Playground", "/query"))
    router.Handle("/query", srv)
    
    log.Println("Servidor iniciado em http://localhost:8080")
    log.Println("GraphQL Playground: http://localhost:8080/")
    log.Fatal(http.ListenAndServe(":8080", router))
}

Testando a API

Usando o Playground

Acesse http://localhost:8080/ e teste as queries:

Criar um usuário:

mutation {
  createUser(input: {
    name: "João Silva"
    email: "joao@exemplo.com"
  }) {
    id
    name
    email
    createdAt
  }
}

Criar um post:

mutation {
  createPost(input: {
    title: "Introdução ao GraphQL com Go"
    content: "GraphQL é uma linguagem de consulta..."
    authorId: "<user-id>"
    status: PUBLISHED
  }) {
    id
    title
    status
    author {
      name
      email
    }
  }
}

Buscar posts com filtros:

query {
  posts(status: PUBLISHED) {
    id
    title
    status
    author {
      name
    }
    comments {
      text
      author {
        name
      }
    }
  }
}

Subscription (em tempo real):

subscription {
  commentAdded(postId: "<post-id>") {
    id
    text
    author {
      name
    }
    createdAt
  }
}

Testes Automatizados

Testando Resolvers

Crie graph/resolver_test.go:

package graph

import (
    "context"
    "testing"
    
    "github.com/seuusuario/go-graphql-api/graph/model"
    "github.com/stretchr/testify/assert"
    "github.com/stretchr/testify/require"
)

func TestCreateUser(t *testing.T) {
    resolver := NewResolver()
    ctx := context.Background()
    
    t.Run("criar usuário com sucesso", func(t *testing.T) {
        input := model.CreateUserInput{
            Name:  "João Silva",
            Email: "joao@exemplo.com",
        }
        
        user, err := resolver.Mutation().CreateUser(ctx, input)
        
        require.NoError(t, err)
        assert.NotEmpty(t, user.ID)
        assert.Equal(t, input.Name, user.Name)
        assert.Equal(t, input.Email, user.Email)
    })
    
    t.Run("não permite email duplicado", func(t *testing.T) {
        input := model.CreateUserInput{
            Name:  "Outro João",
            Email: "joao@exemplo.com",
        }
        
        _, err := resolver.Mutation().CreateUser(ctx, input)
        
        assert.Error(t, err)
        assert.Contains(t, err.Error(), "email já cadastrado")
    })
}

func TestCreatePost(t *testing.T) {
    resolver := NewResolver()
    ctx := context.Background()
    
    // Setup: criar autor
    author, _ := resolver.Mutation().CreateUser(ctx, model.CreateUserInput{
        Name:  "Autor Teste",
        Email: "autor@teste.com",
    })
    
    t.Run("criar post com sucesso", func(t *testing.T) {
        input := model.CreatePostInput{
            Title:    "Post de Teste",
            Content:  "Conteúdo do post",
            AuthorID: author.ID,
        }
        
        post, err := resolver.Mutation().CreatePost(ctx, input)
        
        require.NoError(t, err)
        assert.NotEmpty(t, post.ID)
        assert.Equal(t, input.Title, post.Title)
        assert.Equal(t, model.PostStatusDraft, post.Status)
    })
    
    t.Run("erro com autor inexistente", func(t *testing.T) {
        input := model.CreatePostInput{
            Title:    "Post Inválido",
            Content:  "Conteúdo",
            AuthorID: "autor-inexistente",
        }
        
        _, err := resolver.Mutation().CreatePost(ctx, input)
        
        assert.Error(t, err)
        assert.Contains(t, err.Error(), "autor não encontrado")
    })
}

func TestQueryPosts(t *testing.T) {
    resolver := NewResolver()
    ctx := context.Background()
    
    // Setup
    author, _ := resolver.Mutation().CreateUser(ctx, model.CreateUserInput{
        Name:  "Autor",
        Email: "autor@teste.com",
    })
    
    draft := model.PostStatusDraft
    published := model.PostStatusPublished
    
    resolver.Mutation().CreatePost(ctx, model.CreatePostInput{
        Title:    "Post Rascunho",
        Content:  "...",
        AuthorID: author.ID,
        Status:   &draft,
    })
    
    resolver.Mutation().CreatePost(ctx, model.CreatePostInput{
        Title:    "Post Publicado",
        Content:  "...",
        AuthorID: author.ID,
        Status:   &published,
    })
    
    t.Run("buscar todos os posts", func(t *testing.T) {
        posts, err := resolver.Query().Posts(ctx, nil)
        
        require.NoError(t, err)
        assert.Len(t, posts, 2)
    })
    
    t.Run("buscar posts por status", func(t *testing.T) {
        status := model.PostStatusPublished
        posts, err := resolver.Query().Posts(ctx, &status)
        
        require.NoError(t, err)
        assert.Len(t, posts, 1)
        assert.Equal(t, "Post Publicado", posts[0].Title)
    })
}

func TestFieldResolvers(t *testing.T) {
    resolver := NewResolver()
    ctx := context.Background()
    
    // Criar dados relacionados
    author, _ := resolver.Mutation().CreateUser(ctx, model.CreateUserInput{
        Name:  "Autor",
        Email: "autor@teste.com",
    })
    
    post, _ := resolver.Mutation().CreatePost(ctx, model.CreatePostInput{
        Title:    "Post",
        Content:  "...",
        AuthorID: author.ID,
    })
    
    t.Run("resolver Post.Author", func(t *testing.T) {
        resolvedAuthor, err := resolver.Post().Author(ctx, post)
        
        require.NoError(t, err)
        assert.Equal(t, author.ID, resolvedAuthor.ID)
        assert.Equal(t, author.Name, resolvedAuthor.Name)
    })
}

Executando Testes

go test ./graph/... -v
go test ./graph/... -cover
go test ./graph/... -race

Melhores Práticas

1. Validação de Dados

func validateCreateUser(input model.CreateUserInput) error {
    if input.Name == "" {
        return fmt.Errorf("nome é obrigatório")
    }
    if input.Email == "" {
        return fmt.Errorf("email é obrigatório")
    }
    if !strings.Contains(input.Email, "@") {
        return fmt.Errorf("email inválido")
    }
    return nil
}

2. Autenticação com Context

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, "não autorizado", http.StatusUnauthorized)
            return
        }
        
        ctx := context.WithValue(r.Context(), "user", user)
        next.ServeHTTP(w, r.WithContext(ctx))
    })
}

3. Rate Limiting

import "golang.org/x/time/rate"

var limiter = rate.NewLimiter(rate.Limit(10), 20) // 10 req/s, burst 20

func rateLimitMiddleware(next http.Handler) http.Handler {
    return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
        if !limiter.Allow() {
            http.Error(w, "rate limit exceeded", http.StatusTooManyRequests)
            return
        }
        next.ServeHTTP(w, r)
    })
}

4. Logging

import "log/slog"

func loggingMiddleware(next http.Handler) http.Handler {
    return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
        start := time.Now()
        next.ServeHTTP(w, r)
        slog.Info("request",
            "method", r.Method,
            "path", r.URL.Path,
            "duration", time.Since(start),
        )
    })
}

Próximos Passos

Agora que você domina o básico de GraphQL com Go:

  1. Persistência de Dados: Integre com PostgreSQL ou MongoDB
  2. Caching: Implemente Redis para queries frequentes
  3. Observabilidade: Adicione métricas Prometheus e tracing
  4. Federation: Explore GraphQL federation para microserviços

Explore mais tutoriais de Go:

FAQ

Q: GraphQL é mais lento que REST?
R: Não necessariamente. A sobrecarga do parser GraphQL é mínima comparada à economia de requisições HTTP. Com caching adequado, GraphQL pode ser mais rápido.

Q: Posso usar GraphQL com banco de dados NoSQL?
R: Sim, gqlgen é agnóstico de banco de dados. Você implementa os resolvers para usar qualquer storage.

Q: Como faço cache de queries GraphQL?
R: Use DataLoader para batching/N+1 e Redis para cache de queries frequentes.

Q: É possível usar GraphQL com autenticação JWT?
R: Sim, basta adicionar um middleware que valida o token e injeta o usuário no context.


Última atualização: 11 de fevereiro de 2026