MÓDULO 09 · CONCEITO 04 DE 14

GraphQL

schema SDL · resolvers · N+1 e DataLoader · paginação Relay · federation

Tempo de leitura ~22 min Pré-requisito Conceito 03 — gRPC e Protocol Buffers Próximo 05 · Reverse Proxy

Schema e Schema Definition Language (SDL)

O schema GraphQL é o contrato central — define todos os tipos disponíveis, as relações entre eles, e as operações que os clientes podem executar. É escrito em SDL (Schema Definition Language), uma sintaxe independente de linguagem que ferramentas e runtimes interpretam para gerar código, validar queries e oferecer autocompletion.

Tipos escalares e objetos

# Tipos escalares built-in
# Int, Float, String, Boolean, ID

# Tipos escalares customizados
scalar DateTime
scalar UUID
scalar Money  # centavos como Int, mas com semântica explícita

# Enum
enum OrderStatus {
  PENDING
  PROCESSING
  SHIPPED
  DELIVERED
  CANCELLED
}

# Tipo objeto — campos não-nullable por padrão com !
type OrderItem {
  id: ID!
  productId: ID!
  productName: String!
  quantity: Int!
  unitPriceCents: Int!
  totalPriceCents: Int!  # campo derivado — calculado no resolver
}

type Address {
  street: String!
  city: String!
  state: String!
  postalCode: String!
  country: String!
}

type Customer {
  id: ID!
  name: String!
  email: String!
  # Relação — lista de pedidos com paginação (veremos adiante)
  orders(
    first: Int
    after: String
    status: OrderStatus
  ): OrderConnection!
}

type Order {
  id: ID!
  customer: Customer!        # relação — resolver faz join/fetch
  items: [OrderItem!]!       # lista não-nullable de não-nullables
  status: OrderStatus!
  shippingAddress: Address
  subtotalCents: Int!
  shippingCostCents: Int!
  totalCents: Int!
  createdAt: DateTime!
  updatedAt: DateTime!

Input types — para mutações

# Input types são separados de tipos de output — boa prática
input OrderItemInput {
  productId: ID!
  quantity: Int!
}

input AddressInput {
  street: String!
  city: String!
  state: String!
  postalCode: String!
  country: String!
}

input CreateOrderInput {
  customerId: ID!
  items: [OrderItemInput!]!
  shippingAddress: AddressInput!
}

input UpdateOrderStatusInput {
  orderId: ID!
  status: OrderStatus!
  reason: String  # opcional — sem !
}

Tipos especiais: Query, Mutation, Subscription

type Query {
  # Busca simples
  order(id: ID!): Order       # retorna null se não encontrado
  customer(id: ID!): Customer

  # Listagem com filtros e paginação (Relay Cursor Connections)
  orders(
    first: Int
    after: String
    last: Int
    before: String
    filter: OrderFilterInput
  ): OrderConnection!

  # Busca textual
  searchProducts(query: String!, limit: Int = 20): [Product!]!

  # Campo de saúde (útil para verificação de schema)
  _health: Boolean!
}

type Mutation {
  createOrder(input: CreateOrderInput!): CreateOrderPayload!
  updateOrderStatus(input: UpdateOrderStatusInput!): UpdateOrderStatusPayload!
  cancelOrder(id: ID!, reason: String): CancelOrderPayload!
}

type Subscription {
  # Evento push quando status de um pedido muda
  orderStatusChanged(orderId: ID!): OrderStatusChangedEvent!
  # Todos os pedidos de um cliente em tempo real
  customerOrderUpdates(customerId: ID!): OrderEvent!
}

# Payload types para mutações — padrão recomendado
# Embute o objeto criado/modificado + erros de negócio no type system
type CreateOrderPayload {
  order: Order            # null se houve erro
  errors: [UserError!]!  # lista vazia em sucesso
}

type UserError {
  field: [String!]   # caminho do campo com erro (ex: ["items", "0", "quantity"])
  message: String!
}

type OrderStatusChangedEvent {
  order: Order!
  previousStatus: OrderStatus!
  newStatus: OrderStatus!
  changedAt: DateTime!
}
nota Nullable vs Non-nullable: no GraphQL, campos com ! nunca retornam null — se o resolver retornar null ou lançar erro, o null "borbulha" até o campo nullable ancestral mais próximo. Isso significa que usar ! indiscriminadamente pode tornar respostas inteiras nulas por causa de um campo secundário com erro. Projete nulabilidade pensando em fault isolation: prefira nullable em campos que podem falhar independentemente.

Interfaces e Union types

# Interface — múltiplos tipos implementam campos comuns
interface Node {
  id: ID!
}

interface Timestamped {
  createdAt: DateTime!
  updatedAt: DateTime!
}

type Order implements Node & Timestamped {
  id: ID!
  createdAt: DateTime!
  updatedAt: DateTime!
  # ... outros campos
}

# Union — um campo pode retornar um de N tipos não relacionados
union SearchResult = Order | Product | Customer

type Query {
  search(query: String!): [SearchResult!]!
}

# Na query, o cliente usa inline fragments para acessar campos específicos:
# query {
#   search(query: "notebook") {
#     ... on Product { id name price }
#     ... on Order { id status }
#     ... on Customer { id name email }
#   }
# }

Operações: Query, Mutation, Subscription

Todas as operações GraphQL são enviadas via HTTP POST para um único endpoint (tipicamente /graphql). O body JSON contém query (a operação em SDL), variables (parâmetros externos) e opcionalmente operationName (quando o documento contém múltiplas operações nomeadas).

Queries — leitura com precisão cirúrgica

# O cliente pede exatamente o que precisa — nem mais, nem menos

# Query simples sem variáveis
query {
  order(id: "order-abc") {
    id
    status
    totalCents
    customer {
      name
      email
    }
    items {
      productName
      quantity
      totalPriceCents
    }
  }
}

# Query com variáveis (forma correta — sem interpolação de string)
query GetOrder($orderId: ID!) {
  order(id: $orderId) {
    id
    status
    items {
      productName
      quantity
    }
  }
}
# Variables JSON: { "orderId": "order-abc" }

# Múltiplos recursos em uma requisição (o grande ganho vs REST)
query DashboardData($customerId: ID!) {
  customer(id: $customerId) {
    name
    email
  }
  orders(filter: { customerId: $customerId, status: PROCESSING }, first: 5) {
    edges {
      node {
        id
        status
        totalCents
        createdAt
      }
    }
    pageInfo {
      hasNextPage
      endCursor
    }
  }
}

# Aliases — buscar o mesmo campo com parâmetros diferentes
query CompareOrders {
  order1: order(id: "order-abc") { status totalCents }
  order2: order(id: "order-xyz") { status totalCents }
}

# Fragments — reutilizar seleções de campos
fragment OrderSummary on Order {
  id
  status
  totalCents
  createdAt
}

query RecentOrders($customerId: ID!) {
  customer(id: $customerId) {
    orders(first: 10) {
      edges {
        node {
          ...OrderSummary  # reutiliza o fragment
        }
      }
    }
  }
}

# Directives — comportamento condicional na query
query GetOrderMaybeWithAddress($orderId: ID!, $includeAddress: Boolean!) {
  order(id: $orderId) {
    id
    status
    shippingAddress @include(if: $includeAddress) {
      street
      city
    }
    # @skip é o inverso — omite se condição for true
    subtotalCents @skip(if: $includeAddress)
  }
}

Mutations — escrita com payload estruturado

mutation CreateOrder($input: CreateOrderInput!) {
  createOrder(input: $input) {
    order {
      id
      status
      totalCents
      createdAt
    }
    errors {
      field
      message
    }
  }
}
# Variables:
# {
#   "input": {
#     "customerId": "cust-123",
#     "items": [
#       { "productId": "prod-1", "quantity": 2 }
#     ],
#     "shippingAddress": {
#       "street": "Rua X, 100",
#       "city": "São Paulo",
#       "state": "SP",
#       "postalCode": "01310-100",
#       "country": "BR"
#     }
#   }
# }

# Múltiplas mutations em sequência (executadas na ordem)
mutation BatchOperations($input1: ..., $input2: ...) {
  op1: updateOrderStatus(input: $input1) { ... }
  op2: cancelOrder(id: "order-xyz") { ... }
  # atenção: se op1 falha, op2 ainda executa — não há transação automática
}
atenção Erros de negócio no type system vs erros de protocolo: há duas escolas de erros em GraphQL. A abordagem "errors as data" (recomendada) retorna erros de negócio no campo errors do payload da mutation — o HTTP status é sempre 200 e o campo data está presente. A abordagem convencional usa o campo errors de nível superior do protocolo GraphQL. Erros de protocolo (autenticação, query malformada, campo inexistente) sempre vão para o nível superior. Misturar as duas abordagens gera confusão no cliente.

Resolvers — a camada de execução

Um resolver é uma função associada a um campo do schema que sabe como obter o valor desse campo. O runtime GraphQL executa resolvers em uma árvore que espelha a estrutura da query — cada campo tem seu resolver, e resolvers filhos recebem o objeto pai como primeiro argumento.

Assinatura de um resolver

# A assinatura padrão de um resolver (pseudocódigo agnóstico de linguagem):
# resolve(parent, args, context, info) → valor

# parent: o objeto retornado pelo resolver pai (null para campos raiz)
# args:   os argumentos passados ao campo na query
# context: objeto compartilhado entre todos os resolvers de uma requisição
#          (autenticação, dataloaders, conexão com banco, logger)
# info:   metadados sobre o campo atual, schema, path — raro na prática

Cadeia de resolvers — como a execução funciona

Dado o schema e a query abaixo, o runtime executa os resolvers nesta ordem:

# Query
query {
  order(id: "abc") {    # → Query.order(parent=null, args={id:"abc"})
    status              # → Order.status(parent=order, args={})
    customer {          # → Order.customer(parent=order, args={})
      name              # → Customer.name(parent=customer, args={})
    }
    items {             # → Order.items(parent=order, args={})
      productName       # → OrderItem.productName(parent=item, args={})
      quantity          # → OrderItem.quantity(parent=item, args={})
    }
  }
}

Resolvers de campos escalares (status, name, productName) tipicamente simplesmente retornam a propriedade do objeto pai — o runtime tem resolvers default que fazem isso. Resolvers de campos de relação (customer, items) precisam ir ao banco ou a outro serviço buscar os dados.

Resolvers de campos de mesmo nível na query são executados em paralelo (Query.order e Query.customer, por exemplo). Resolvers de campos filhos executam após o pai retornar. Isso significa que o grafo de execução é determinístico em relação à árvore da query, mas paralelo dentro de cada nível.

Context — objeto compartilhado por requisição

// O context é construído uma vez por requisição e passado a todos os resolvers
// Contém: usuário autenticado, dataloaders, conexão com banco, logger, trace

// Exemplo de context (C# com Hot Chocolate)
public class AppContext
{
    public UserPrincipal? CurrentUser { get; init; }
    public OrderDataLoader OrderLoader { get; init; }
    public CustomerDataLoader CustomerLoader { get; init; }
    public IOrderRepository Orders { get; init; }
    public ILogger Logger { get; init; }
    public Activity? Trace { get; init; }
}

O problema N+1 e DataLoader

O problema N+1 é o desafio de performance mais comum em GraphQL. Ocorre quando um resolver de relação dispara uma query de banco separada para cada item da lista pai — resultando em 1 query para a lista e N queries para os relacionamentos.

O problema

# Query que lista pedidos com dados do cliente
query {
  orders(first: 20) {
    edges {
      node {
        id
        customer {   # ← problema: 20 queries separadas para customer!
          name
          email
        }
      }
    }
  }
}

# Sem DataLoader, o resolver Customer resolve assim:
# 1. SELECT * FROM orders LIMIT 20          → 20 orders
# 2. SELECT * FROM customers WHERE id = ?   → customer do order[0]
# 3. SELECT * FROM customers WHERE id = ?   → customer do order[1]
# ... até order[19]
# Total: 21 queries. Para 100 pedidos = 101 queries.

DataLoader — batching e caching

DataLoader é um utilitário (originalmente da Facebook, agora disponível em todas as linguagens) que resolve o N+1 através de dois mecanismos: batching (agrupa todas as chaves solicitadas dentro de um tick do event loop em uma única query) e caching (deduplicação de chaves iguais dentro da mesma requisição).

# Com DataLoader, a sequência de execução é:
# 1. SELECT * FROM orders LIMIT 20                    → 20 orders
# 2. (todos os resolvers de Customer registram suas chaves no loader)
# 3. SELECT * FROM customers WHERE id IN (?)          → 1 query com N IDs únicos
# Total: 2 queries independente de quantos pedidos.

# Pseudocódigo do DataLoader:
class CustomerDataLoader:
    def __init__(self, db):
        self._db = db
        self._cache = {}
        self._queue = []

    async def load(self, customer_id: str) -> Customer:
        # Adiciona à fila, retorna uma Promise
        ...

    async def _batch_load(self, keys: list[str]) -> list[Customer]:
        # Chamado com TODOS os keys acumulados no tick atual
        customers = await self._db.query(
            "SELECT * FROM customers WHERE id = ANY($1)", keys
        )
        # Retorna na mesma ordem dos keys (DataLoader exige isso)
        customer_map = {c.id: c for c in customers}
        return [customer_map.get(k) for k in keys]
atenção DataLoader por requisição, não por instância global: o cache do DataLoader deve ser criado por requisição (no context), nunca como singleton global. Um DataLoader global criaria problemas de isolation entre requisições diferentes e potencialmente exporia dados de um usuário para outro.

Além do N+1: query complexity e profundidade

GraphQL permite que clientes escrevam queries arbitrariamente complexas. Uma query maliciosa (ou apenas descuidada) pode criar explosão combinatória:

# Query com profundidade excessiva (cada level dispara novos fetches)
query {
  orders {
    customer {
      orders {
        customer {
          orders {  # ← recursão de dados via grafo circular
            ...
          }
        }
      }
    }
  }
}

# Proteções necessárias no servidor:
# 1. Max depth limit (ex: profundidade máxima 7)
# 2. Query complexity scoring — cada campo tem custo, lista tem custo multiplicado
# 3. Query timeout
# 4. Persisted queries (apenas queries pré-aprovadas em produção)

Paginação — Relay Cursor Connections

GraphQL não prescreve um padrão de paginação — mas a especificação Relay Cursor Connections se tornou o padrão de facto recomendado. Ao contrário de paginação por offset (page=2&size=20), cursor connections são estáveis: inserções ou deleções entre páginas não causam itens duplicados ou pulados.

Estrutura do Connection type

# O padrão Relay usa três tipos relacionados:
# 1. Connection — o resultado paginado
# 2. Edge — o wrapper de cada item com cursor e metadados
# 3. PageInfo — informações de navegação

type OrderConnection {
  edges: [OrderEdge!]!
  pageInfo: PageInfo!
  totalCount: Int!  # campo adicional comum (não no padrão Relay, mas útil)
}

type OrderEdge {
  node: Order!      # o item em si
  cursor: String!   # opaque cursor — posição deste item
}

type PageInfo {
  hasNextPage: Boolean!
  hasPreviousPage: Boolean!
  startCursor: String   # cursor do primeiro item desta página
  endCursor: String     # cursor do último item desta página
}

# Uso na query — forward pagination
query {
  orders(first: 10) {
    edges {
      cursor
      node { id status totalCents }
    }
    pageInfo {
      hasNextPage
      endCursor
    }
    totalCount
  }
}

# Próxima página — passe o endCursor como after
query {
  orders(first: 10, after: "cursor-opaco-da-pagina-anterior") {
    edges {
      node { id status }
    }
    pageInfo {
      hasNextPage
      endCursor
    }
  }
}

# Backward pagination (menos comum)
query {
  orders(last: 10, before: "cursor-inicio") {
    ...
  }
}

Implementação de cursors

Cursors são opacos para o cliente — o servidor pode usar qualquer estratégia interna. As mais comuns:

Tratamento de erros

GraphQL tem um modelo de erros em duas camadas que frequentemente confunde desenvolvedores vindos de REST.

Camada 1 — erros de protocolo (campo errors de nível superior)

Erros de sintaxe na query, campos inexistentes no schema, violações de tipo, autenticação falha, e erros não tratados em resolvers vão para o campo errors do protocolo GraphQL. O HTTP status retornado pode ser 200 mesmo com erros (a query foi processada, apenas parcialmente).

{
  "data": {
    "order": null  // campo virou null porque o resolver lançou erro
  },
  "errors": [
    {
      "message": "Order not found",
      "locations": [{ "line": 2, "column": 3 }],
      "path": ["order"],
      "extensions": {
        "code": "NOT_FOUND",
        "orderId": "order-xyz"
      }
    }
  ]
}

Camada 2 — erros de negócio no payload da mutation

Erros de validação, regras de negócio e estados inválidos devem ser expressos no type system como parte do payload da mutation. O HTTP status é sempre 200, data está presente, e o cliente olha para data.createOrder.errors:

{
  "data": {
    "createOrder": {
      "order": null,
      "errors": [
        {
          "field": ["items", "0", "quantity"],
          "message": "Quantidade deve ser maior que zero"
        },
        {
          "field": ["customerId"],
          "message": "Cliente não encontrado"
        }
      ]
    }
  },
  "errors": []  // sem erros de protocolo
}
nota HTTP 200 para tudo — vantagens e desvantagens: retornar 200 em erros de negócio torna o comportamento uniforme para clientes e simplifica retry logic. A desvantagem é que logs e APMs que analisam HTTP status codes não detectam erros de negócio — é preciso parsear o JSON. A solução é instrumentação explícita no servidor: contar respostas com data.*.errors não vazios como "erros de aplicação" nas métricas.

Subscriptions — dados em tempo real

Subscriptions são o mecanismo de GraphQL para dados em tempo real — o cliente recebe eventos do servidor à medida que ocorrem. A subscrição é estabelecida uma vez e o servidor envia dados quando o evento relevante acontece.

Transporte: WebSocket e SSE

O protocolo graphql-ws (substituto do depreciado subscriptions-transport-ws) usa WebSocket com uma fase de handshake e framing de mensagens. Para ambientes onde WebSocket é problemático (proxies corporativos, balanceadores sem suporte), SSE (Server-Sent Events) com o protocolo graphql-sse é alternativa viável — permite somente streaming server→client, que é o caso de 90% das subscriptions reais.

Implementação no servidor

# A subscription precisa de:
# 1. Definição no schema
# 2. Resolver que retorna um async generator / observable
# 3. Infraestrutura de pub/sub (Redis, Kafka, EventEmitter)

type Subscription {
  orderStatusChanged(orderId: ID!): OrderStatusChangedEvent!
}

# Resolver (pseudocódigo)
async def order_status_changed(root, info, orderId):
    pubsub = info.context.pubsub
    async for event in pubsub.subscribe(f"order:{orderId}:status"):
        yield OrderStatusChangedEvent(
            order=await info.context.order_loader.load(orderId),
            previous_status=event.previous_status,
            new_status=event.new_status,
            changed_at=event.changed_at,
        )
atenção Subscriptions e escalonamento horizontal: quando há múltiplas instâncias do servidor, a subscrição de um cliente está conectada a uma instância específica. Se o evento ocorre em outra instância, o cliente não recebe. Soluções: Redis pub/sub como broker compartilhado entre instâncias, Kafka como event bus, ou um serviço dedicado de push (como Ably ou Pusher). A complexidade operacional de subscriptions distribuídas é um argumento forte para considerar alternativas (Server-Sent Events com polling inteligente, ou gRPC streaming bidirecional).

Introspection — o schema é auto-descritivo

GraphQL tem um sistema de introspection built-in: qualquer cliente pode consultar o schema do servidor usando queries especiais no namespace __. Isso possibilita ferramentas como GraphiQL, Apollo Studio e VS Code GraphQL extension funcionarem sem configuração manual.

# Query de introspection — listar todos os tipos
query IntrospectionQuery {
  __schema {
    types {
      name
      kind
      description
      fields {
        name
        type { name kind ofType { name kind } }
        args { name type { name } }
      }
    }
    queryType { name }
    mutationType { name }
    subscriptionType { name }
  }
}

# Inspecionar um tipo específico
query {
  __type(name: "Order") {
    name
    kind
    fields {
      name
      type {
        name
        kind
        ofType { name kind }  # para tipos wrapped como [Order!]!
      }
    }
  }
}

# O campo __typename em qualquer nível — útil para Union types
query {
  search(query: "notebook") {
    __typename  # retorna "Product", "Order", etc.
    ... on Product { id name }
    ... on Order { id status }
  }
}
atenção Desabilitar introspection em produção: introspection expõe a estrutura completa do schema — incluindo campos internos, tipos de admin e nomes que dão pistas sobre a arquitetura. É boa prática desabilitar introspection em produção (mantendo apenas em desenvolvimento e staging) e usar persisted queries para restringir as operações que clientes podem executar.

Federation — GraphQL em microsserviços

Em arquiteturas com múltiplos microsserviços, cada serviço pode possuir parte do schema GraphQL. Federation (Apollo Federation v2 é o padrão dominante) permite compor esses subgraphs em um supergraph unificado, com um router que distribui partes da query para os serviços corretos.

Arquitetura federation

# Serviço de Orders — subgraph de pedidos
# Define Order e estende Customer com campo orders
type Order @key(fields: "id") {
  id: ID!
  status: OrderStatus!
  totalCents: Int!
  customerId: ID!
}

# Estende Customer (definido no serviço de Customers)
extend type Customer @key(fields: "id") {
  id: ID! @external          # campo externo — vem do serviço de Customers
  orders(first: Int): OrderConnection!
}

type Query {
  order(id: ID!): Order
}

# Serviço de Customers — subgraph de clientes
type Customer @key(fields: "id") {
  id: ID!
  name: String!
  email: String!
  # orders vem do serviço de Orders, não precisa definir aqui
}

# Router (Apollo Router ou GraphQL Mesh)
# Recebe a query do cliente, determina quais subgraphs precisam ser consultados,
# executa as sub-queries em paralelo quando possível, e combina os resultados

# Query do cliente — o router resolve transparentemente
query {
  customer(id: "cust-123") {
    name          # → Customers subgraph
    email         # → Customers subgraph
    orders {      # → Orders subgraph (com reference resolver)
      edges {
        node {
          id
          status
          totalCents
        }
      }
    }
  }
}

Reference resolvers — como o router une os dados

# O serviço de Orders precisa de um "reference resolver" para Customer
# Quando o router busca orders de um customer, ele passa a entidade Customer
# com apenas o campo @key (id) para o serviço de Orders completar

# Resolver de referência no serviço de Orders:
def resolve_reference(customer, info):
    # customer tem apenas {"id": "cust-123"} (o @key)
    # retorna os dados de orders para esse customer
    return {"id": customer["id"]}  # orders são resolvidos pelo campo resolver normal
nota Overhead de federation: federation adiciona latência de round-trip entre router e subgraphs, além de complexidade operacional. Para sistemas com poucos serviços ou onde a performance é crítica, um único GraphQL server com resolvers que chamam microsserviços internos (via gRPC ou REST) é muitas vezes mais simples e eficiente. Federation vale quando há times autônomos que precisam evoluir seus schemas independentemente.

Persisted Queries e Automatic Persisted Queries (APQ)

Em produção, enviar a query completa como texto em cada requisição tem desvantagens: queries grandes aumentam o payload, e clientes arbitrários podem enviar queries maliciosas. Persisted Queries resolvem isso com queries pré-registradas — identificadas por hash e armazenadas no servidor.

Automatic Persisted Queries (APQ)

APQ é um protocolo que funciona sem configuração de lista branca: na primeira requisição, o cliente envia apenas o hash SHA-256 da query. Se o servidor não reconhece, retorna PERSISTED_QUERY_NOT_FOUND; o cliente então envia o hash + a query completa; o servidor armazena e processa. Nas requisições seguintes, apenas o hash é enviado.

# Requisição 1 (cache miss)
POST /graphql
{
  "extensions": {
    "persistedQuery": {
      "version": 1,
      "sha256Hash": "b60b...hash"
    }
  }
}
# Resposta: { "errors": [{ "message": "PersistedQueryNotFound" }] }

# Requisição 2 (enviando query completa)
POST /graphql
{
  "query": "query GetOrder($id: ID!) { order(id: $id) { id status } }",
  "extensions": {
    "persistedQuery": { "version": 1, "sha256Hash": "b60b...hash" }
  }
}
# Servidor armazena, responde com dados

# Requisições seguintes — apenas hash
POST /graphql
{
  "extensions": {
    "persistedQuery": { "version": 1, "sha256Hash": "b60b...hash" }
  },
  "variables": { "id": "order-abc" }
}

Trusted Documents — allowlist estrita

Para segurança máxima, Trusted Documents (também chamado de Safelisted Queries) requerem que apenas queries pré-aprovadas em deploy possam ser executadas. O CI/CD extrai todas as queries dos clientes, calcula os hashes, e os armazena no servidor. Queries ad-hoc são rejeitadas. Isso elimina o risco de introspection e de queries de alta complexidade em produção.

Quando usar GraphQL

GraphQL vence quando:

GraphQL perde quando:

dica GraphQL como camada de agregação: o padrão mais efetivo é GraphQL na camada de apresentação (BFF) e REST ou gRPC nas comunicações service-to-service. O GraphQL server agrega chamadas a múltiplos serviços backend e entrega ao cliente exatamente o que o frontend precisa — sem ser o protocolo de comunicação interna.

Comparação por linguagem

Implementação de um servidor GraphQL com Query de pedidos, Mutation de criação, e DataLoader para evitar N+1 em cada linguagem.

C# — Hot Chocolate (ChilliCream)
// Schema-first abordado com code-first em C#
// Adicionar: HotChocolate.AspNetCore, HotChocolate.Data

// Tipos de domínio mapeados para GraphQL via anotações
[GraphQLName("Order")]
public class OrderType
{
    public string Id { get; set; } = null!;
    public string CustomerId { get; set; } = null!;
    public OrderStatus Status { get; set; }
    public int TotalCents { get; set; }
    public DateTime CreatedAt { get; set; }

    // Resolver de relação — chamado quando o campo customer é solicitado
    [GraphQLName("customer")]
    public async Task<CustomerType> GetCustomerAsync(
        [Service] ICustomerDataLoader loader) =>
        await loader.LoadAsync(CustomerId);

    // Resolver de lista — items do pedido
    [GraphQLName("items")]
    public async Task<IReadOnlyList<OrderItemType>> GetItemsAsync(
        [Service] IOrderItemDataLoader loader) =>
        await loader.LoadAsync(Id);
}

// DataLoader para evitar N+1
public class CustomerDataLoader : BatchDataLoader<string, CustomerType>
{
    private readonly ICustomerRepository _repo;

    public CustomerDataLoader(ICustomerRepository repo, IBatchScheduler scheduler)
        : base(scheduler) => _repo = repo;

    protected override async Task<IReadOnlyDictionary<string, CustomerType>>
        LoadBatchAsync(IReadOnlyList<string> keys, CancellationToken ct)
    {
        var customers = await _repo.GetByIdsAsync(keys, ct);
        return customers.ToDictionary(c => c.Id);
    }
}

// Input types para mutations
public record CreateOrderInput(
    string CustomerId,
    IReadOnlyList<OrderItemInput> Items,
    AddressInput ShippingAddress);

public record OrderItemInput(string ProductId, int Quantity);

// Payload da mutation com erros no type system
public class CreateOrderPayload
{
    public OrderType? Order { get; init; }
    public IReadOnlyList<UserError> Errors { get; init; } = [];
}

public record UserError(string Message, IReadOnlyList<string>? Field = null);

// Query type
public class Query
{
    [UsePaging(IncludeTotalCount = true)]    // Relay Connections automático
    [UseFiltering]
    [UseSorting]
    public IQueryable<OrderType> GetOrders([Service] IOrderRepository repo) =>
        repo.Query();

    public async Task<OrderType?> GetOrder(
        string id,
        [Service] IOrderRepository repo) =>
        await repo.GetByIdAsync(id);
}

// Mutation type
public class Mutation
{
    public async Task<CreateOrderPayload> CreateOrderAsync(
        CreateOrderInput input,
        [Service] IOrderRepository repo,
        [Service] ICustomerRepository customerRepo)
    {
        // Validação de negócio
        var customer = await customerRepo.GetByIdAsync(input.CustomerId);
        if (customer is null)
            return new CreateOrderPayload
            {
                Errors = [new UserError("Cliente não encontrado", ["customerId"])]
            };

        if (!input.Items.Any())
            return new CreateOrderPayload
            {
                Errors = [new UserError("Lista de itens não pode estar vazia", ["items"])]
            };

        var order = await repo.CreateAsync(new OrderDomain
        {
            CustomerId = input.CustomerId,
            Items = input.Items.Select(i => new OrderItemDomain
            {
                ProductId = i.ProductId,
                Quantity = i.Quantity,
            }).ToList(),
        });

        return new CreateOrderPayload { Order = order.ToGraphQL() };
    }
}

// Program.cs — registro
builder.Services
    .AddGraphQLServer()
    .AddQueryType<Query>()
    .AddMutationType<Mutation>()
    .AddType<OrderType>()
    .AddDataLoader<CustomerDataLoader>()
    .AddDataLoader<OrderItemDataLoader>()
    .AddFiltering()
    .AddSorting()
    .AddInstrumentation()  // OpenTelemetry
    .ModifyRequestOptions(o =>
    {
        o.IncludeExceptionDetails = builder.Environment.IsDevelopment();
        o.ExecutionTimeout = TimeSpan.FromSeconds(30);
    });

app.MapGraphQL();  // endpoint /graphql com Banana Cake Pop UI em dev

Hot Chocolate usa BatchDataLoader<TKey, TValue> que gerencia automaticamente o batching por tick — sem configurar manualmente o scheduler. Paginação Relay é gerada com [UsePaging].

Python — Strawberry
import strawberry
from strawberry.types import Info
from strawberry.dataloader import DataLoader
from typing import Annotated
import asyncio

# Tipos definidos com dataclasses + anotações Strawberry
@strawberry.type
class OrderItem:
    id: strawberry.ID
    product_name: str
    quantity: int
    unit_price_cents: int

    @strawberry.field
    def total_price_cents(self) -> int:
        return self.quantity * self.unit_price_cents

@strawberry.type
class Customer:
    id: strawberry.ID
    name: str
    email: str

@strawberry.type
class Order:
    id: strawberry.ID
    customer_id: strawberry.ID  # campo interno
    status: "OrderStatus"
    total_cents: int
    created_at: datetime

    @strawberry.field
    async def customer(self, info: Info) -> Customer:
        # DataLoader — sem N+1
        return await info.context.customer_loader.load(self.customer_id)

    @strawberry.field
    async def items(self, info: Info) -> list[OrderItem]:
        return await info.context.order_items_loader.load(self.id)

@strawberry.enum
class OrderStatus(Enum):
    PENDING = "PENDING"
    PROCESSING = "PROCESSING"
    SHIPPED = "SHIPPED"

# Input types
@strawberry.input
class OrderItemInput:
    product_id: strawberry.ID
    quantity: int

@strawberry.input
class CreateOrderInput:
    customer_id: strawberry.ID
    items: list[OrderItemInput]

# Payload com erros
@strawberry.type
class UserError:
    message: str
    field: list[str] | None = None

@strawberry.type
class CreateOrderPayload:
    order: Order | None
    errors: list[UserError]

# Context com DataLoaders (criado por requisição)
class AppContext:
    def __init__(self, db, user):
        self.db = db
        self.user = user

        async def batch_load_customers(keys: list[str]) -> list[Customer]:
            rows = await db.fetchall(
                "SELECT * FROM customers WHERE id = ANY($1)", keys
            )
            customer_map = {r["id"]: Customer(**r) for r in rows}
            return [customer_map.get(k) for k in keys]  # mesma ordem dos keys!

        self.customer_loader = DataLoader(load_fn=batch_load_customers)

# Query e Mutation
@strawberry.type
class Query:
    @strawberry.field
    async def order(self, id: strawberry.ID, info: Info) -> Order | None:
        row = await info.context.db.fetchone(
            "SELECT * FROM orders WHERE id = $1", id
        )
        return Order(**row) if row else None

    @strawberry.field
    async def orders(
        self,
        info: Info,
        first: int = 20,
        after: str | None = None,
        status: OrderStatus | None = None,
    ) -> "OrderConnection":
        # implementação de cursor pagination
        ...

@strawberry.type
class Mutation:
    @strawberry.mutation
    async def create_order(
        self,
        input: CreateOrderInput,
        info: Info,
    ) -> CreateOrderPayload:
        if not input.items:
            return CreateOrderPayload(
                order=None,
                errors=[UserError(
                    message="Lista de itens não pode estar vazia",
                    field=["items"]
                )]
            )

        customer = await info.context.customer_loader.load(input.customer_id)
        if not customer:
            return CreateOrderPayload(
                order=None,
                errors=[UserError(
                    message="Cliente não encontrado",
                    field=["customerId"]
                )]
            )

        order_row = await info.context.db.fetchone(
            "INSERT INTO orders (...) VALUES (...) RETURNING *",
            input.customer_id,
        )
        return CreateOrderPayload(
            order=Order(**order_row),
            errors=[]
        )

# Schema e servidor
schema = strawberry.Schema(
    query=Query,
    mutation=Mutation,
    # Subscription pode ser adicionado aqui
)

# FastAPI integration
from strawberry.fastapi import GraphQLRouter

async def get_context(request: Request, db=Depends(get_db)) -> AppContext:
    user = await authenticate(request)
    return AppContext(db=db, user=user)

graphql_app = GraphQLRouter(schema, context_getter=get_context)
app.include_router(graphql_app, prefix="/graphql")

Strawberry usa DataLoader com load_fn assíncrona — a função recebe a lista de todas as keys acumuladas no tick. O context é criado por request via context_getter, garantindo isolation.

Go — gqlgen
// gqlgen é schema-first: schema SDL gera interfaces Go
// graph/schema.graphqls — define o schema
// gqlgen.yml — configura mapeamentos

// Após rodar "go run github.com/99designs/gqlgen generate":
// graph/generated/generated.go  — código gerado (não editar)
// graph/model/models_gen.go     — structs geradas
// graph/resolver.go             — stubs a implementar

// graph/resolver.go — implementação dos resolvers
type Resolver struct {
    OrderRepo    repository.OrderRepository
    CustomerRepo repository.CustomerRepository
}

// graph/order.resolvers.go
func (r *queryResolver) Order(ctx context.Context, id string) (*model.Order, error) {
    order, err := r.OrderRepo.GetByID(ctx, id)
    if err != nil {
        if errors.Is(err, repository.ErrNotFound) {
            return nil, nil // GraphQL retorna null para Not Found
        }
        return nil, fmt.Errorf("buscar pedido: %w", err)
    }
    return orderToModel(order), nil
}

// Resolver de relação — Customer dentro de Order
func (r *orderResolver) Customer(ctx context.Context, obj *model.Order) (*model.Customer, error) {
    // Usar DataLoader para evitar N+1
    return For(ctx).CustomerLoader.Load(ctx, obj.CustomerID)
}

// graph/dataloader.go — implementação com dataloaden
// go install github.com/vektah/dataloaden@latest
// dataloaden CustomerLoader string *model.Customer

type Loaders struct {
    CustomerLoader *CustomerLoader
    OrderItemLoader *OrderItemLoader
}

func NewLoaders(customerRepo repository.CustomerRepository) *Loaders {
    return &Loaders{
        CustomerLoader: NewCustomerLoader(CustomerLoaderConfig{
            MaxBatch: 100,
            Wait:     1 * time.Millisecond, // agrupa requests em 1ms
            Fetch: func(ids []string) ([]*model.Customer, []error) {
                customers, err := customerRepo.GetByIDs(context.Background(), ids)
                if err != nil {
                    errs := make([]error, len(ids))
                    for i := range errs {
                        errs[i] = err
                    }
                    return nil, errs
                }
                // Retornar na MESMA ORDEM que ids (obrigatório no DataLoader)
                customerMap := make(map[string]*model.Customer, len(customers))
                for _, c := range customers {
                    customerMap[c.ID] = c
                }
                result := make([]*model.Customer, len(ids))
                errs := make([]error, len(ids))
                for i, id := range ids {
                    result[i] = customerMap[id]
                    if result[i] == nil {
                        errs[i] = fmt.Errorf("customer %s not found", id)
                    }
                }
                return result, errs
            },
        }),
    }
}

// Middleware para injetar loaders no contexto
func DataLoaderMiddleware(loaders *Loaders, next http.Handler) http.Handler {
    return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
        // Cria nova instância de Loaders por requisição para isolation
        reqLoaders := NewLoaders(loaders.customerRepo)
        ctx := context.WithValue(r.Context(), loadersKey, reqLoaders)
        next.ServeHTTP(w, r.WithContext(ctx))
    })
}

func For(ctx context.Context) *Loaders {
    return ctx.Value(loadersKey).(*Loaders)
}

// Mutation resolver — graph/order.resolvers.go
func (r *mutationResolver) CreateOrder(
    ctx context.Context,
    input model.CreateOrderInput,
) (*model.CreateOrderPayload, error) {
    if len(input.Items) == 0 {
        return &model.CreateOrderPayload{
            Errors: []*model.UserError{{
                Message: "Lista de itens não pode estar vazia",
                Field:   []string{"items"},
            }},
        }, nil
    }

    order, err := r.OrderRepo.Create(ctx, &repository.CreateOrderParams{
        CustomerID: input.CustomerID,
        Items: lo.Map(input.Items, func(i model.OrderItemInput, _ int) repository.OrderItem {
            return repository.OrderItem{
                ProductID: i.ProductID,
                Quantity:  i.Quantity,
            }
        }),
    })
    if err != nil {
        var notFound *repository.NotFoundError
        if errors.As(err, ¬Found) {
            return &model.CreateOrderPayload{
                Errors: []*model.UserError{{
                    Message: notFound.Message,
                    Field:   []string{"customerId"},
                }},
            }, nil
        }
        return nil, fmt.Errorf("criar pedido: %w", err)
    }

    return &model.CreateOrderPayload{
        Order: orderToModel(order),
    }, nil
}

// server.go — configuração do servidor
func main() {
    repo := repository.New(db)
    loaders := NewLoaders(repo)

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

    // Limites de segurança
    srv.AddTransport(transport.Websocket{
        KeepAlivePingInterval: 10 * time.Second,
    })
    srv.SetQueryCache(lru.New(1000))
    srv.Use(extension.Introspection{})          // desabilitar em prod
    srv.Use(extension.AutomaticPersistedQuery{
        Cache: lru.New(100),
    })
    srv.Use(extension.FixedComplexityLimit(300)) // bloquear queries complexas

    http.Handle("/graphql", DataLoaderMiddleware(loaders, srv))
    log.Fatal(http.ListenAndServe(":8080", nil))
}

gqlgen é estritamente schema-first: o SDL define a interface, e o gqlgen generate cria os stubs que você implementa. O DataLoader usa Wait: 1ms para agrupar requisições sem latência perceptível.

Decisões de engenharia

GraphQL vs REST
Use GraphQL quando há múltiplos clientes com necessidades diferentes (mobile pede menos campos que web), dados altamente relacionais que o cliente precisa navegar em uma requisição, ou times de front com autonomia para iterar sem aguardar endpoints novos. Use REST quando a API é simples e orientada a recursos, quando HTTP caching nativo via CDN é crítico, ou quando a equipe é pequena e a pilha do GraphQL (DataLoader, complexity, persisted queries) traria overhead desproporcional.
Schema-first vs Code-first
Schema-first (gqlgen em Go, graphql-core em Python manual) define o SDL e gera código — a fonte da verdade é o schema, facilitando revisão em PRs e tooling de lint de schema. Code-first (Hot Chocolate em C#, Strawberry em Python) define o schema via anotações e classes — reduz duplicação entre tipos de domínio e tipos GraphQL, mas obscurece o schema gerado. Prefira schema-first quando há múltiplos consumidores (mobile, web, terceiros) que precisam do SDL como contrato explícito; code-first quando o schema é resultado direto do modelo de domínio existente.
DataLoader vs JOIN no banco
DataLoader resolve N+1 via batching de IDs em WHERE id IN (?) — funciona bem quando a relação é entre entidades de serviços distintos ou quando o ORM não suporta joins eficientes para o grafo de relações. JOINs no banco são mais eficientes para relações dentro do mesmo banco (menos round-trips, plano de execução único, uso de índices em FK), mas acoplam o resolver ao schema SQL. O padrão saudável é JOIN dentro do mesmo serviço/banco, DataLoader entre serviços ou para relações que cruzam fronteiras de domínio.
Federation vs Aggregation Gateway
Federation (Apollo Federation v2) permite que cada equipe publique seu próprio subgraph com schema autônomo — o router compõe em runtime. Vale quando há ≥3 equipes que precisam evoluir schemas independentemente sem coordenação. Aggregation Gateway é um servidor GraphQL único que chama múltiplos serviços backend via REST ou gRPC — mais simples de operar e depurar, sem o overhead de federation. Para a maioria dos sistemas (<3 equipes de front), aggregation gateway é preferível; federation é escolha para plataformas com dezenas de equipes autônomas.

Exercícios práticos

  1. Implemente um schema GraphQL para um sistema de blog com tipos Post, Author e Comment. Adicione queries com paginação Relay Connections, mutations createPost e addComment com payload de erros, e um DataLoader para evitar N+1 ao buscar autores de uma lista de posts. Critério: query posts(first: 10) { author { name } } deve gerar exatamente 2 queries ao banco (uma para posts, uma para todos os autores), verificável via logging. Mutations devem retornar errors: [] em sucesso e errors: [UserError] em falha sem alterar o HTTP status.
  2. Reproduza o problema N+1 intencionalmente: crie um resolver de author em Post que faz uma query de banco por chamada. Use logging para contar as queries. Depois adicione DataLoader e verifique que o número de queries cai para 2 independente da quantidade de posts. Critério: com 20 posts e sem DataLoader, o log deve registrar 21 queries; com DataLoader, exatamente 2 — independente de quantos posts a query retornar (testar com 1, 10 e 50 posts).
  3. Implemente query complexity analysis: atribua custo 1 para campos escalares e custo n × custo_do_filho para listas (onde n é o argumento first). Rejeite queries com complexidade total acima de 500. Critério: query posts(first: 10) { title } (custo ≈10) é executada normalmente; query posts(first: 100) { comments(first: 100) { text } } (custo 10.000) é rejeitada com erro QUERY_COMPLEXITY_EXCEEDED antes de qualquer acesso ao banco.
  4. Adicione uma Subscription postPublished que emite eventos quando novos posts são criados. Use Redis pub/sub como broker e teste com dois clientes WebSocket simultâneos. Critério: ao criar um post via mutation, ambos os clientes WebSocket abertos recebem o evento em até 200ms. Reiniciar um cliente e criar um segundo post faz apenas o cliente reconectado receber o novo evento (sem replay de eventos anteriores, semântica at-most-once).
  5. Desafio de federation: divida o schema de blog em dois subgraphs — users-service (Author, autenticação) e content-service (Post, Comment). Configure um Apollo Router local que una os dois. Critério: Apollo Router exposto na porta 4000; query { author(id: "1") { name posts { title } } } retorna corretamente com dados vindos de ambos os subgraphs; logs do router mostram duas chamadas a subgraphs distintos para essa query.

Perguntas de entrevista

Explique o problema N+1 no GraphQL e como o DataLoader o resolve internamente.

O N+1 ocorre porque GraphQL executa resolvers de forma lazy por campo: ao listar 20 pedidos, o resolver de customer é chamado 20 vezes — uma por pedido — cada vez disparando uma query separada ao banco. Resultado: 1 query para os pedidos + 20 queries para customers = 21 queries.

O DataLoader resolve isso com dois mecanismos: batching e caching. Batching funciona assim: em vez de executar cada load(id) imediatamente, o DataLoader acumula todas as chaves solicitadas dentro de um mesmo "tick" do event loop (uma microtask queue) e, ao final do tick, chama a batchFn uma única vez com todas as chaves. A batchFn executa WHERE id IN (?) e retorna os valores na mesma ordem dos keys. Caching garante que múltiplos resolvers solicitando a mesma chave na mesma requisição resultem em uma única entrada no batch — sem duplicatas.

Ponto crítico: o DataLoader deve ser instanciado por requisição, não como singleton global. Um singleton global significaria que o cache persiste entre requisições — exposição de dados de um usuário para outro. O context do GraphQL (construído uma vez por request) é o lugar correto para criar os loaders.

Qual a diferença entre erros de protocolo GraphQL e erros de negócio? Como você modela cada um?

O protocolo GraphQL tem um campo errors de nível superior que recebe: erros de sintaxe na query, campos inexistentes no schema, violações de tipo, e exceções não tratadas em resolvers. O HTTP status pode ser 200 mesmo com erros nesse campo — a query foi processada, mas algum resolver falhou.

Erros de negócio (validação, regras de domínio, estado inválido) devem ser modelados no type system como parte do payload da mutation — padrão "errors as data". O payload retorna order: Order | null e errors: [UserError!]!. Em sucesso, order está presente e errors é lista vazia; em falha de negócio, order é null e errors descreve o problema com campo e mensagem.

Por que essa separação importa: erros de protocolo são para o framework (cliente GraphQL, SDK), erros de negócio são para o usuário da aplicação. Misturar os dois faz o cliente tratar INVALID_PRODUCT_ID como se fosse um bug de serialização. Além disso, APMs que monitoram o campo errors do protocolo não detectam erros de negócio — é preciso instrumentação específica para contar payloads com errors não vazios.

Como funciona o Apollo Federation v2 e quando a complexidade operacional se justifica?

Na Federation, cada serviço publica um subgraph com uma porção do schema total e uma diretiva @key que identifica como entidades são referenciadas entre subgraphs. Um router central (Apollo Router ou open-source alternativas) recebe queries do cliente, determina quais subgraphs são necessários via query planning, executa sub-queries em paralelo quando possível, e combina os resultados.

O mecanismo central é o reference resolver: quando o router precisa de campos de uma entidade que pertence a outro subgraph, ele envia a entidade com apenas os campos @key para o subgraph responsável, que completa os campos faltantes. Isso permite que Order.customer.name funcione mesmo quando Order e Customer vivem em serviços distintos.

A complexidade se justifica quando: há ≥3 equipes autônomas que precisam publicar schemas independentemente sem coordenação central, ou quando o schema é suficientemente grande que um servidor monolítico se torna um gargalo organizacional. Para a maioria dos sistemas (<3 equipes de front), um aggregation gateway — um servidor GraphQL único que chama microsserviços backend via REST/gRPC — é mais simples de operar, debugar e monitorar, com latência menor por ter menos round-trips.

Por que Relay Cursor Connections é preferível à paginação por offset? Quais são as limitações do cursor-based?

Paginação por offset (LIMIT 20 OFFSET 40) tem dois problemas clássicos: instabilidade e performance. Instabilidade: se um item é inserido ou deletado enquanto o usuário pagina, a página 3 pode conter um item duplicado da página 2 ou pular um item. Performance: OFFSET 1000000 LIMIT 20 faz o banco percorrer 1.000.020 linhas e descartar as primeiras 1.000.000 — mesmo com índice, o custo cresce linearmente.

Cursor Connections resolvem isso: o cursor é um ponteiro opaco para uma posição específica na ordenação. A query equivalente é WHERE (created_at, id) < (cursor_ts, cursor_id) ORDER BY created_at DESC LIMIT 20 — keyset pagination. Com índice composto em (created_at, id), essa query é O(log n) independentemente da página.

Limitações: não permite saltar diretamente para a página 50 (é necessário percorrer as páginas anteriores). O totalCount requer COUNT(*) separado, que pode ser lento em tabelas grandes. A ordenação deve ser determinística — se dois itens têm o mesmo created_at, o cursor precisa de desempate (geralmente o ID) para evitar itens pulados na virada de página.

Quais são os principais vetores de ataque específicos do GraphQL e como mitigá-los?

Introspection leakage: introspection expõe o schema completo — campos de admin, tipos internos, nomes que revelam arquitetura. Mitigação: desabilitar introspection em produção (manter apenas em dev/staging) e usar persisted queries ou trusted documents para restringir operações permitidas.

Query complexity attacks: um cliente pode enviar uma query que cria explosão combinatória — orders { customer { orders { customer { ... } } } }. Sem proteção, o servidor executa isso e gera N^depth queries ao banco. Mitigação: query complexity scoring (custo por campo, custo multiplicado para listas), max depth limit, e timeout por operação.

Batching attacks: GraphQL permite enviar um array de operações em uma requisição. Um atacante pode enviar 1.000 mutations em um único request para escalar um ataque de força bruta. Mitigação: rate limiting no nível de operação (não apenas de request HTTP), limitar o tamanho máximo do batch.

Denial of Service via aliases: a query { a1: expensiveField a2: expensiveField ... a1000: expensiveField } executa o mesmo resolver caro 1.000 vezes sem violar o limite de profundidade. O complexity scoring deve contabilizar aliases.

Stack de mitigação recomendada em produção: disable introspection + trusted documents (allowlist estrita) + complexity limit + depth limit + rate limiting por operação + query timeout + APQ (Automatic Persisted Queries) para reduzir payload e permitir caching.

Referências

  1. spec GraphQL Specification (October 2021) — GraphQL Foundation. spec.graphql.org — Especificação normativa do sistema de tipos, execução, introspection e protocolo de resposta. Leitura obrigatória para entender o comportamento de null bubbling, regras de coerção de tipos e execução paralela de campos.
  2. spec Relay Cursor Connections Specification — Meta / Relay Team. relay.dev/graphql/connections.htm — Padrão de paginação cursor-based amplamente adotado: define Connection, Edge, PageInfo e a semântica de first/after/last/before. Base de implementação para qualquer servidor GraphQL que precise de paginação estável.
  3. spec GraphQL over HTTP Specification — GraphQL Foundation (2023). graphql.github.io/graphql-over-http — Especificação do transporte HTTP para GraphQL: Content-Type, status codes, multipart responses e incremental delivery com @defer/@stream. Referência para implementar transporte correto além do simples POST.
  4. artigo GraphQL: A Data Query Language — Lee Byron, Facebook (2015). engineering.fb.com — Artigo original descrevendo a motivação e design do GraphQL no Facebook: substituir REST endpoints proliferados, suportar múltiplos clientes com shapes de dados diferentes, e declarar o que o cliente precisa.
  5. livro Production Ready GraphQL — Marc-André Giroux (2020). productionreadygraphql.com — Cobre segurança, performance, versionamento e operação de GraphQL em produção: complexity analysis, persisted queries, schema governance, monitoramento e erros. Referência prática indispensável para times além do "hello world".
  6. docs Apollo Federation v2 — Apollo GraphQL. apollographql.com/docs/federation — Arquitetura de federation para composição de subgraphs em microsserviços: @key, @external, @requires, reference resolvers, Apollo Router e query planning. Fonte de referência para sistemas com múltiplas equipes publicando schemas autônomos.
  7. docs DataLoader v2 — The Guild / graphql/dataloader. github.com/graphql/dataloader — Implementação de referência do DataLoader com documentação detalhada de batching, caching, priming e comportamento em erros. Inclui exemplos de como garantir a ordem dos resultados no batch (requisito crítico).
  8. artigo Principled GraphQL — Apollo GraphQL (2019). principledgraphql.com — 10 princípios de design para APIs GraphQL de escala empresarial: integrity (schema único), agility (schema colaborativo), operations (performance e observabilidade). Útil como guia de revisão para novos schemas.
  9. docs Hot Chocolate Documentation — ChilliCream. chillicream.com/docs/hotchocolate — Servidor GraphQL mais completo para .NET: code-first com anotações, BatchDataLoader, paginação Relay automática com [UsePaging], filtering e sorting via EF Core, OpenTelemetry e Banana Cake Pop UI.
  10. docs Strawberry GraphQL — Strawberry. strawberry.rocks — Servidor GraphQL para Python usando dataclasses e type hints. Integra com FastAPI (context_getter por request), suporta DataLoader assíncrono, subscriptions via WebSocket e SSE, e geração automática de schema SDL.
  11. docs gqlgen Documentation — 99designs. gqlgen.com — Servidor GraphQL schema-first para Go: SDL define a interface, gqlgen generate cria stubs tipados. Inclui guias de DataLoader (dataloaden), complexity limiting, e websocket transport. O approach schema-first garante que o SDL é a fonte de verdade.
  12. vídeo GraphQL: A Query Language for your API — Lee Byron, React Europe (2015). YouTube — Palestra original de Lee Byron que introduziu GraphQL ao público: demonstra o problema de REST com múltiplos clientes, como SDL resolve a proliferação de endpoints, e o modelo de execução baseado em resolvers. Contexto histórico essencial para entender as motivações de design.