Pular para o conteúdo

Trilha de Go · parte 26 de 37

GraphQL com gqlgen: Abordagem Schema-First, Resolvers e DataLoader

Como construir uma API GraphQL com gqlgen na abordagem schema-first, com resolvers e DataLoader para evitar o problema N+1.

Publicada em · 6 min de leitura

Tamanho da fonte:
Largura da coluna:

GraphQL com gqlgen: Abordagem Schema-First, Resolvers e DataLoader

Enquanto arquiteturas REST operam sobre endpoints rígidos focados em recursos (/pedidos, /usuarios), aplicações modernas frequentemente sofrem com Overfetching (baixar dezenas de campos desnecessários pela rede) ou Underfetching (precisar disparar 5 requisições HTTP sequenciais para renderizar uma única tela). O GraphQL resolve esse problema permitindo que o cliente defina exatamente a forma e os campos dos dados que deseja receber em uma única requisição.

No ecossistema Go, a biblioteca padrão absoluta para construção de servidores GraphQL é o gqlgen (github.com/99designs/gqlgen). Ao contrário de bibliotecas em outras linguagens que dependem de reflexão dinâmica e anotações pesadas em runtime, o gqlgen segue a filosofia Schema-First: você escreve o contrato estrito em um arquivo .graphqls, e o compilador gera código Go estático, com zero-reflection e 100% de type-safety.


1. O Pipeline do Compilador gqlgen

O gqlgen atua como um gerador de código baseado no schema da sua aplicação, desacoplando o parsing e validação do GraphQL da implementação das suas regras de negócio:

Pipeline de Geração de Código do gqlgen
figura 1 · Pipeline de Geração de Código do gqlgen

Componentes Gerados:

  • graph/schema.graphqls: O contrato universal escrito na Schema Definition Language (SDL) do GraphQL.
  • gqlgen.yml: Arquivo de configuração que controla o mapeamento de pacotes, models e nomes de arquivos.
  • graph/model/models_gen.go: Structs Go geradas automaticamente a partir dos tipos do schema (Inputs, Enums, Payloads).
  • graph/generated/generated.go: Motor interno type-safe de parsing, validação e execução de queries.
  • graph/schema.resolvers.go: O único arquivo onde você, desenvolvedor, escreve código Go com a lógica de negócio dos resolvers.

2. Definindo o Schema (schema.graphqls)

Vamos modelar um domínio de e-commerce contendo User e Order:

type User {
  id: ID!
  name: String!
  email: String!
  orders: [Order!]! # Relacionamento 1:N
  createdAt: String!
}

type Order {
  id: ID!
  userId: ID!
  totalAmount: Float!
  status: OrderStatus!
  createdAt: String!
}

enum OrderStatus {
  PENDING
  PAID
  CANCELLED
}

input CreateUserInput {
  name: String!
  email: String!
}

input CreateOrderInput {
  userId: ID!
  totalAmount: Float!
}

type Query {
  user(id: ID!): User
  users: [User!]!
  orders(userId: ID!): [Order!]!
}

type Mutation {
  createUser(input: CreateUserInput!): User!
  createOrder(input: CreateOrderInput!): Order!
}

3. Configurando o Mapeamento (gqlgen.yml)

O gqlgen.yml permite reaproveitar modelos de domínio existentes em vez de gerar novas structs para tudo:

schema:
  - "graph/*.graphqls"

exec:
  filename: graph/generated/generated.go
  package: generated

model:
  filename: graph/model/models_gen.go
  package: model

resolver:
  layout: follow-schema
  dir: graph
  package: graph

# Instruímos o gqlgen a NÃO resolver orders automaticamente na query pai,
# forçando a criação de um Field Resolver dedicado
models:
  User:
    fields:
      orders:
        resolver: true

Após configurar, execute no terminal:

go run github.com/99designs/gqlgen generate

4. Implementando Resolvers

O gqlgen gera as interfaces necessárias em schema.resolvers.go. Veja a implementação dos resolvers de Query, Mutation e do Field Resolver relacional:

package graph

import (
	"context"
	"fmt"
	"time"

	"meu-projeto/graph/model"
)

type Resolver struct {
	// Injeção de dependências (repositórios, use cases, DB pools)
}

// CreateUser é o resolver da Mutation createUser
func (r *mutationResolver) CreateUser(ctx context.Context, input model.CreateUserInput) (*model.User, error) {
	user := &model.User{
		ID:        fmt.Sprintf("usr_%d", time.Now().UnixNano()),
		Name:      input.Name,
		Email:     input.Email,
		CreatedAt: time.Now().UTC().Format(time.RFC3339),
	}

	// Persiste no banco de dados via repositório...
	return user, nil
}

// User é o resolver da Query user(id)
func (r *queryResolver) User(ctx context.Context, id string) (*model.User, error) {
	// Busca usuário por ID no repositório...
	return &model.User{
		ID:        id,
		Name:      "Alan Turing",
		Email:     "alan@turing.org",
		CreatedAt: time.Now().UTC().Format(time.RFC3339),
	}, nil
}

// Orders é o Field Resolver executado quando o cliente solicita o campo 'orders' dentro de User
func (r *userResolver) Orders(ctx context.Context, obj *model.User) ([]*model.Order, error) {
	// AVISO: Se implementado com consulta direta ao banco, causa o Problema do N+1!
	// Veja a seção 5 abaixo para a solução correta com DataLoader.
	return GetOrdersLoader(ctx).Load(obj.ID)
}

5. O Problema do N+1 e a Solução com DataLoader

Quando um cliente executa a query:

query {
  users {
    id
    name
    orders {
      id
      totalAmount
    }
  }
}

Se a listagem inicial retornar 100 usuários, a engine do GraphQL executará o método Orders(ctx, obj) 100 vezes individualmente, disparando 100 queries separadas no banco de dados (SELECT * FROM orders WHERE user_id = ?).

DataLoader: Mitigando o N+1 no GraphQL
figura 2 · DataLoader: Mitigando o N+1 no GraphQL

Como o DataLoader Funciona:

  1. Cada chamada Orders(ctx, user) não acessa a rede imediatamente; ela registra o user.ID em uma fila em memória e aguarda o próximo tick de execução.
  2. O DataLoader consolida todas as chaves solicitadas no mesmo micro-ciclo de I/O em um único slice de IDs ([]string{"usr_1", "usr_2", "usr_3", ...}).
  3. Uma única Batch Function é disparada no banco de dados: $$\text{SELECT * FROM orders WHERE user_id IN (‘usr_1’, ‘usr_2’, …);}$$
  4. O resultado é distribuído em memória de volta para as promises/canais correspondentes de cada resolver.

Implementação de um DataLoader em Go

Utilizando o pacote padrão github.com/vikstrous/dataloadgen:

package dataloader

import (
	"context"
	"time"

	"meu-projeto/graph/model"
	"github.com/vikstrous/dataloadgen"
)

type LoaderKey struct{}

type OrderBatcher struct {
	// Injeção do repositório ou *sql.DB / pgxpool
}

// BatchGetOrders busca pedidos de múltiplos usuários em uma única query SQL IN
func (b *OrderBatcher) BatchGetOrders(ctx context.Context, userIDs []string) ([][]*model.Order, []error) {
	// 1. Executa: SELECT * FROM orders WHERE user_id = ANY($1)
	// 2. Mapeia as linhas retornadas para um mapa: map[string][]*model.Order
	ordersByUserID := make(map[string][]*model.Order)

	// 3. Monta o slice de resposta estritamente na mesma ordem dos userIDs recebidos
	result := make([][]*model.Order, len(userIDs))
	for i, id := range userIDs {
		result[i] = ordersByUserID[id]
	}

	return result, nil
}

// Middleware para instanciar e anexar DataLoaders por requisição
func Middleware(batcher *OrderBatcher) func(http.Handler) http.Handler {
	return func(next http.Handler) http.Handler {
		return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
			loader := dataloadgen.NewLoader(
				batcher.BatchGetOrders,
				dataloadgen.WithWait(2*time.Millisecond), // Janela de espera para acumular batch
				dataloadgen.WithBatchCapacity(100),       // Tamanho máximo do lote
			)

			ctx := context.WithValue(r.Context(), LoaderKey{}, loader)
			next.ServeHTTP(w, r.WithContext(ctx))
		})
	}
}

// For recupera o loader da requisição atual
func For(ctx context.Context) *dataloadgen.Loader[string, []*model.Order] {
	return ctx.Value(LoaderKey{}).(*dataloadgen.Loader[string, []*model.Order])
}

6. Autenticação e Diretivas Customizadas

No GraphQL, diretivas customizadas permitem aplicar regras de autorização de forma declarativa diretamente no schema .graphqls:

directive @hasRole(role: Role!) on FIELD_DEFINITION

enum Role {
  ADMIN
  USER
}

type Mutation {
  deleteUser(id: ID!): Boolean! @hasRole(role: ADMIN)
}

Configurando a diretiva no runtime do gqlgen:

config := generated.Config{Resolvers: &graph.Resolver{}}

config.Directives.HasRole = func(ctx context.Context, obj any, next graphql.Resolver, role model.Role) (any, error) {
	// Lê o usuário autenticado injetado pelo Middleware de JWT
	claims, ok := middleware.UserFromContext(ctx)
	if !ok {
		return nil, fmt.Errorf("acesso negado: não autenticado")
	}

	if !slices.Contains(claims.Roles, string(role)) {
		return nil, fmt.Errorf("acesso proibido: exige permissão %s", role)
	}

	return next(ctx)
}

7. Servindo o GraphQL Server e o Playground

A inicialização do servidor integra o handler do GraphQL com a interface interativa do GraphQL Playground:

package main

import (
	"log"
	"net/http"

	"meu-projeto/graph"
	"meu-projeto/graph/generated"
	"meu-projeto/internal/middleware"

	"github.com/99designs/gqlgen/graphql/handler"
	"github.com/99designs/gqlgen/graphql/playground"
)

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

	mux := http.NewServeMux()

	// 1. Interface Web do Playground para desenvolvimento
	mux.Handle("/", playground.Handler("GraphQL Explorer", "/query"))

	// 2. Endpoint de execução das queries GraphQL protegido por middlewares
	pipeline := middleware.Chain(
		middleware.RequestID(),
		middleware.Logger(logger),
		middleware.Authenticate(tokenManager), // Lê JWT do header Authorization
	)
	mux.Handle("POST /query", pipeline(srv))

	log.Println("Servidor GraphQL rodando em http://localhost:8080/")
	http.ListenAndServe(":8080", mux)
}

8. Boas Práticas e Segurança em Produção

  1. Proteção Contra DoS por Complexidade e Profundidade: Um cliente malicioso pode enviar queries recursivas infinitas (user { orders { user { orders { ... } } } }). Use plugins do gqlgen para fixar limites estritos:
    srv.Use(extension.FixedComplexityLimit(200))
  2. Disable Introspection em Produção: Em ambientes de produção pública, desative a introspecção de schema para evitar que agentes externos façam engenharia reversa completa do seu grafo de dados.
  3. Escopo dos DataLoaders: Nunca compartilhe uma instância de DataLoader globalmente entre requisições concorrentes de usuários diferentes. Cada requisição HTTP deve instanciar seu próprio DataLoader isolado via Middleware para evitar vazamento de dados de cache entre clientes.

9. Resumo Conceitual

  • O gqlgen adota a filosofia Schema-First, gerando código estático tipado a partir de arquivos .graphqls.
  • Não utiliza reflexão em tempo de execução, garantindo desempenho de latência comparável a APIs REST puras em Go.
  • Resolvers de campo relacionais executam lazy-loading e exigem DataLoaders para eliminar o problema do N+1.
  • Cada requisição HTTP deve receber sua própria instância de DataLoader via context.Context.
  • Proteja mutações e queries sensíveis usando diretivas customizadas (@hasRole) combinadas com Autenticação e JWT e Middlewares em Go.
  • Monitore a complexidade do grafo com limites de profundidade para garantir a resiliência do microsserviço.

↑ Voltar ao topo

Atalhos de teclado

Abrir busca
/
Alternar tema claro/escuro
t
Aula anterior na série
←
Próxima aula na série
→
Lista de atalhos
?