Go Admin

O que Todo Dev Deve Saber sobre GraphQL em Go com gqlgen: Schema-First e Resolvers Tipados Já leu

Introdução: Por que GraphQL em Go com gqlgen? GraphQL é uma linguagem de query moderna que permite aos clientes solicitar exatamente os dados que precisam, nada mais, nada menos. Quando comparado a REST APIs tradicionais, GraphQL reduz significativamente o over-fetching e under-fetching de dados. Go é uma linguagem compilada, rápida e eficiente, ideal para construir APIs de alta performance. O gqlgen é um gerador de código GraphQL para Go que segue a abordagem schema-first, significa que você define primeiro o contrato da sua API no schema GraphQL, e então o gqlgen gera código tipo-seguro para você implementar. A abordagem schema-first é poderosa porque estabelece um contrato claro entre cliente e servidor antes de qualquer implementação. Você não fica preso a estruturas Go específicas; em vez disso, define o que sua API deve fazer, e o gqlgen cria as interfaces e tipos necessários. Isso torna o desenvolvimento mais organizado, previsível e menos propenso a erros de tipo em tempo de execução.

Introdução: Por que GraphQL em Go com gqlgen?

GraphQL é uma linguagem de query moderna que permite aos clientes solicitar exatamente os dados que precisam, nada mais, nada menos. Quando comparado a REST APIs tradicionais, GraphQL reduz significativamente o over-fetching e under-fetching de dados. Go é uma linguagem compilada, rápida e eficiente, ideal para construir APIs de alta performance. O gqlgen é um gerador de código GraphQL para Go que segue a abordagem schema-first, significa que você define primeiro o contrato da sua API no schema GraphQL, e então o gqlgen gera código tipo-seguro para você implementar.

A abordagem schema-first é poderosa porque estabelece um contrato claro entre cliente e servidor antes de qualquer implementação. Você não fica preso a estruturas Go específicas; em vez disso, define o que sua API deve fazer, e o gqlgen cria as interfaces e tipos necessários. Isso torna o desenvolvimento mais organizado, previsível e menos propenso a erros de tipo em tempo de execução. Vou guiá-lo através do processo completo: desde a instalação, passando pela definição do schema, até a implementação dos resolvers com tipagem completa.

Configuração do Projeto e Instalação

Preparando o Ambiente

Comece criando um novo diretório para seu projeto e inicialize um módulo Go. Você precisará do Go 1.16 ou superior instalado em sua máquina. O gqlgen foi projetado para funcionar com a estrutura padrão de projetos Go, então mantenemos uma organização simples e clara.

mkdir meu-graphql-api
cd meu-graphql-api
go mod init github.com/seu-usuario/meu-graphql-api

Agora instale o gqlgen. A forma recomendada é adicioná-lo como uma dependência indireta através de um arquivo tools.go:

// tools.go
//go:build tools
// +build tools

package main

import (
    _ "github.com/99designs/gqlgen"
)

Execute go mod tidy para baixar a dependência. Depois, crie o arquivo de configuração:

go run github.com/99designs/gqlgen init

Este comando cria:
- gqlgen.yml — configuração do projeto
- graph/schema.graphqls — seu schema GraphQL
- graph/model/models_gen.go — modelos gerados
- Pastas de suporte como graph/resolver.go

Estrutura do Projeto

A estrutura final fica assim:

meu-graphql-api/
├── go.mod
├── go.sum
├── gqlgen.yml
├── graph/
│   ├── schema.graphqls
│   ├── model/
│   │   └── models_gen.go
│   ├── resolver.go
│   └── schema.resolvers.go
├── server.go
└── tools.go

O arquivo gqlgen.yml é o coração da configuração. Ele mapeia seus tipos GraphQL para tipos Go e define como o código é gerado. Por padrão, ele está bem configurado, mas você pode customizá-lo conforme necessário para controlar namespaces de pacotes, caminhos de saída e comportamentos de geração.

Definindo o Schema GraphQL (Schema-First)

Conceitos Fundamentais do Schema

Um schema GraphQL define todos os tipos, queries, mutations e subscriptions disponíveis em sua API. Ele é um contrato explícito entre cliente e servidor. Quando você trabalha com schema-first, este é o ponto de partida: você escreve o schema antes do código Go, e o gqlgen infere quais tipos e funções você precisa implementar.

Abra o arquivo graph/schema.graphqls e defina um schema simples mas realista. Vou criar um exemplo de um sistema de blog:

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

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

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

type Mutation {
  createPost(input: CreatePostInput!): Post!
  updatePost(id: ID!, input: UpdatePostInput!): Post
}

input CreatePostInput {
  title: String!
  content: String!
  authorID: ID!
}

input UpdatePostInput {
  title: String
  content: String
}

Este schema define dois tipos principais (Post e User), as operações de leitura (Query), as operações de escrita (Mutation), e dois tipos de entrada (CreatePostInput e UpdatePostInput). A exclamação ! indica que o campo é obrigatório (não-nulo). Colchetes [] indicam listas.

Gerando Código a partir do Schema

Após definir o schema, execute:

go run github.com/99designs/gqlgen generate

O gqlgen analisa o schema, verifica as dependências e gera código tipo-seguro. Ele cria:
- Interfaces Go para todos os tipos (Mutation, Query, Post, etc.)
- Modelos de dados estruturados
- Um arquivo schema.resolvers.go onde você implementa os resolvers

Este processo elimina a necessidade de você definir manualmente as estruturas Go correspondentes a cada tipo GraphQL. O código gerado é consistente, bem-tipado e segue as melhores práticas de Go.

Implementando Resolvers Tipados

O que é um Resolver?

Um resolver é uma função que implementa a lógica de negócio para um campo específico em seu schema GraphQL. Para cada campo que não é um tipo primitivo de uma única fonte de dados, você precisa de um resolver. No schema anterior, Query.post, Query.user, Mutation.createPost e o campo Post.author são campos que precisam de resolvers porque não podem ser diretamente satisfeitos a partir de uma única fonte de dados.

Quando você executa go run github.com/99designs/gqlgen generate pela primeira vez, o gqlgen cria um arquivo schema.resolvers.go com stubs (esqueletos) de todos os resolvers. Você preencherá estes stubs com sua lógica de negócio.

Implementando Resolvers de Query

Abra graph/schema.resolvers.go e implemente os resolvers de query. Vou criar uma implementação simples com dados em memória:

package graph

import (
    "context"
    "fmt"

    "github.com/seu-usuario/meu-graphql-api/graph/model"
)

// Dados em memória para este exemplo
var (
    users = map[string]*model.User{
        "user1": {
            ID:    "user1",
            Name:  "João Silva",
            Email: "joao@example.com",
        },
    }

    posts = map[string]*model.Post{
        "post1": {
            ID:        "post1",
            Title:     "Introdução a GraphQL",
            Content:   "GraphQL é uma linguagem de query...",
            AuthorID:  "user1",
            CreatedAt: "2024-01-15",
        },
    }
)

// Post resolve o campo Query.post
func (r *queryResolver) Post(ctx context.Context, id string) (*model.Post, error) {
    post, exists := posts[id]
    if !exists {
        return nil, fmt.Errorf("post não encontrado")
    }
    return post, nil
}

// User resolve o campo Query.user
func (r *queryResolver) User(ctx context.Context, id string) (*model.User, error) {
    user, exists := users[id]
    if !exists {
        return nil, fmt.Errorf("usuário não encontrado")
    }
    return user, nil
}

// AllPosts resolve o campo Query.allPosts
func (r *queryResolver) AllPosts(ctx context.Context) ([]*model.Post, error) {
    var result []*model.Post
    for _, post := range posts {
        result = append(result, post)
    }
    return result, nil
}

Note que cada resolver recebe context.Context como primeiro parâmetro. Isso permite rastreamento, cancelamento de operações de longa duração e passagem de valores entre middlewares. Os retornos seguem o padrão Go: resultado, erro.

Resolvendo Campos Aninhados

GraphQL é especial porque permite campos aninhados. Se um cliente solicitar:

{
  post(id: "post1") {
    title
    author {
      name
    }
  }
}

O campo author dentro de Post também precisa de um resolver. Embora nosso modelo Post tenha apenas AuthorID, o schema exige um campo Author de tipo User. Vamos implementar:

// Author resolve o campo Post.author
func (r *postResolver) Author(ctx context.Context, obj *model.Post) (*model.User, error) {
    user, exists := users[obj.AuthorID]
    if !exists {
        return nil, fmt.Errorf("autor não encontrado")
    }
    return user, nil
}

// Posts resolve o campo User.posts (usuário possui múltiplos posts)
func (r *userResolver) Posts(ctx context.Context, obj *model.User) ([]*model.Post, error) {
    var result []*model.Post
    for _, post := range posts {
        if post.AuthorID == obj.ID {
            result = append(result, post)
        }
    }
    return result, nil
}

Este é um ponto crucial: resolvers de campos aninhados recebem o objeto pai como parâmetro (obj *model.Post, obj *model.User). Isso permite resolver o campo usando dados do pai, mantendo a grafo de dados coeso.

Implementando Mutations

Mutations alteram dados. Implementamos seguindo o mesmo padrão:

// CreatePost resolve o campo Mutation.createPost
func (r *mutationResolver) CreatePost(
    ctx context.Context,
    input model.CreatePostInput,
) (*model.Post, error) {
    // Gerar um novo ID (em produção, use UUID ou banco de dados)
    newID := fmt.Sprintf("post%d", len(posts)+1)

    newPost := &model.Post{
        ID:        newID,
        Title:     input.Title,
        Content:   input.Content,
        AuthorID:  input.AuthorID,
        CreatedAt: "2024-01-15", // Em produção, use time.Now()
    }

    posts[newID] = newPost
    return newPost, nil
}

// UpdatePost resolve o campo Mutation.updatePost
func (r *mutationResolver) UpdatePost(
    ctx context.Context,
    id string,
    input model.UpdatePostInput,
) (*model.Post, error) {
    post, exists := posts[id]
    if !exists {
        return nil, fmt.Errorf("post não encontrado")
    }

    if input.Title != nil {
        post.Title = *input.Title
    }
    if input.Content != nil {
        post.Content = *input.Content
    }

    return post, nil
}

Note que UpdatePostInput utiliza ponteiros (*string) porque os campos são opcionais. Um ponteiro nil significa que o campo não foi fornecido, então não deve ser atualizado.

Executando e Testando sua API GraphQL

Configurando o Servidor HTTP

Crie um arquivo server.go na raiz do projeto que inicia o servidor HTTP:

package main

import (
    "log"
    "net/http"
    "os"

    "github.com/99designs/gqlgen/graphql/handler"
    "github.com/99designs/gqlgen/graphql/playground"
    "github.com/seu-usuario/meu-graphql-api/graph"
    "github.com/seu-usuario/meu-graphql-api/graph/generated"
)

const defaultPort = "8080"

func main() {
    port := os.Getenv("PORT")
    if port == "" {
        port = defaultPort
    }

    srv := handler.NewDefaultServer(generated.NewExecutableSchema(generated.Config{
        Resolvers: &graph.Resolver{},
    }))

    http.Handle("/query", srv)
    http.Handle("/", playground.Handler("GraphQL playground", "/query"))

    log.Printf("connect to http://localhost:%s/ for GraphQL playground", port)
    log.Fatal(http.ListenAndServe(":"+port, nil))
}

Este código:
1. Cria um ExecutableSchema — a representação interna do gqlgen de seu schema e resolvers
2. Monta o handler de GraphQL em /query
3. Monta o GraphQL Playground (ferramenta visual para testar queries) em /
4. Inicia o servidor na porta 8080

Testando com GraphQL Playground

Execute o servidor:

go run server.go

Abra http://localhost:8080 em seu navegador. Você verá o GraphQL Playground. Teste uma query simples:

query {
  allPosts {
    id
    title
    author {
      name
      email
    }
  }
}

Teste uma mutation:

mutation {
  createPost(input: {
    title: "Novo Post"
    content: "Conteúdo do novo post"
    authorID: "user1"
  }) {
    id
    title
    createdAt
  }
}

O GraphQL Playground fornece autocompletar (Ctrl+Space), validação em tempo real e documentação integrada do seu schema. Esta é uma ferramenta poderosa para desenvolvimento.

Adicionando Validação e Tratamento de Erros

Em produção, adicione validação robusta. Modifique seu CreatePost:

func (r *mutationResolver) CreatePost(
    ctx context.Context,
    input model.CreatePostInput,
) (*model.Post, error) {
    // Validar entrada
    if input.Title == "" {
        return nil, fmt.Errorf("título não pode estar vazio")
    }
    if len(input.Title) > 200 {
        return nil, fmt.Errorf("título não pode exceder 200 caracteres")
    }
    if input.Content == "" {
        return nil, fmt.Errorf("conteúdo não pode estar vazio")
    }

    // Verificar se o autor existe
    if _, exists := users[input.AuthorID]; !exists {
        return nil, fmt.Errorf("autor com ID %s não encontrado", input.AuthorID)
    }

    newID := fmt.Sprintf("post%d", len(posts)+1)
    newPost := &model.Post{
        ID:       newID,
        Title:    input.Title,
        Content:  input.Content,
        AuthorID: input.AuthorID,
        CreatedAt: "2024-01-15",
    }

    posts[newID] = newPost
    return newPost, nil
}

Erros retornados dos resolvers são automaticamente formatados como erros GraphQL e retornados ao cliente com status HTTP 200 e um campo errors na resposta JSON.

Padrões Avançados e Boas Práticas

Injeção de Dependência

Em aplicações reais, seus resolvers precisam acessar banco de dados, cache, APIs externas, etc. Use injeção de dependência através do Resolver raiz:

// graph/resolver.go
package graph

import (
    "database/sql"
    "log"
)

type Resolver struct {
    db *sql.DB
    log *log.Logger
}

func NewResolver(db *sql.DB, logger *log.Logger) *Resolver {
    return &Resolver{
        db: db,
        log: logger,
    }
}

Agora seus resolvers podem acessar r.db e r.log. No server.go:

func main() {
    // ... setup db

    resolver := graph.NewResolver(db, log.New(os.Stdout, "", log.LstdFlags))

    srv := handler.NewDefaultServer(generated.NewExecutableSchema(generated.Config{
        Resolvers: resolver,
    }))

    // ...
}

DataLoader para Evitar N+1 Queries

Um problema comum: quando você resolve User.posts para múltiplos usuários, executa uma query por usuário (problema N+1). DataLoader agrupa requisições:

Instale: go get github.com/graph-gophers/dataloader/v7

import "github.com/graph-gophers/dataloader/v7"

type loaders struct {
    postsByAuthorID *dataloader.Loader
}

// Criar loader na inicialização
func NewLoaders(db *sql.DB) *loaders {
    return &loaders{
        postsByAuthorID: dataloader.NewBatchedLoader(
            func(ctx context.Context, ids []string) []*dataloader.Result {
                // Buscar todos os posts destes autores em uma única query
                results := make([]*dataloader.Result, len(ids))
                // ... implementar lógica batch
                return results
            },
        ),
    }
}

Use no resolver:

func (r *userResolver) Posts(ctx context.Context, obj *model.User) ([]*model.Post, error) {
    // Usar loader em vez de query individual
    return r.postsByAuthorIDLoader.Load(ctx, obj.ID)
}

Middleware para Logging, Autenticação e Rate Limiting

O gqlgen permite adicionar middleware no handler:

srv := handler.NewDefaultServer(schema)

// Middleware de logging
srv.Use(&logging.Middleware{})

// Middleware de autenticação
srv.Use(&auth.Middleware{})

// Middleware de rate limiting
srv.Use(&ratelimit.Middleware{})

Você pode implementar estes middlewares usando a interface graphql.OperationMiddleware ou graphql.ResponseMiddleware fornecida pelo gqlgen.

Conclusão

Você aprendeu como utilizar o gqlgen para construir APIs GraphQL tipadas e robustas em Go seguindo a abordagem schema-first. Primeiro, você define o contrato da API no schema GraphQL, o que fornece clareza e documentação automática. Segundo, o gqlgen gera código tipo-seguro baseado no schema, eliminando classes inteiras de erros de tipo e mantendo cliente e servidor sincronizados. Terceiro, implementar resolvers é direto porque cada função tem uma assinatura bem-definida, parâmetros explícitos e retornos seguindo padrões Go idiomáticos — você sabe exatamente o que implementar e o resultado é código limpo, testável e manutenível.

Referências


Artigos relacionados