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ística | REST | GraphQL |
|---|---|---|
| Obter dados relacionados | Múltiplas requisições | Uma requisição |
| Over-fetching | Comum | Impossível |
| Versionamento | Necessário | Desnecessário |
| Introspection | Limitada | Completa |
| Type Safety | Manual | Nativa |
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:
- Persistência de Dados: Integre com PostgreSQL ou MongoDB
- Caching: Implemente Redis para queries frequentes
- Observabilidade: Adicione métricas Prometheus e tracing
- Federation: Explore GraphQL federation para microserviços
Explore mais tutoriais de Go:
- Go e MongoDB: CRUD e Agregações
- Go e Redis: Cache e Session Store
- Go Microserviços: Arquitetura e Práticas
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