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!
}
! 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
}
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]
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:
- Base64 de ID:
base64("order:abc123")— simples, funciona quando a ordenação é por ID sequencial ou UUID ordered - Base64 de timestamp + ID:
base64("2024-01-15T10:30:00Z:abc123")— para ordenação por data com desempate - Offset serializado:
base64("offset:40")— fácil de implementar mas perde a vantagem de estabilidade - Keyset pagination:
WHERE (created_at, id) < (?, ?)— a implementação SQL que corresponde ao cursor baseado em campo de ordenação, muito eficiente com índice composto
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
}
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,
)
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 }
}
}
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
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:
- Múltiplos clientes com necessidades diferentes — mobile pede menos campos que web, dashboard pede mais que a app — sem versionar APIs ou criar endpoints específicos
- Dados altamente relacionais — quando o cliente precisa navegar um grafo de relações em uma única requisição (ex: pedido → cliente → histórico → recomendações)
- Times de frontend com autonomia — o time de front pode iterar sem depender do time de backend para criar novos endpoints ou adicionar campos
- BFF (Backend for Frontend) — GraphQL como camada de agregação que consolida múltiplos microsserviços backend
- APIs com schema estável que evoluem gradualmente — adicionar campos é não-breaking por padrão
GraphQL perde quando:
- Simplicidade é prioridade — para CRUD simples, REST é menos complexo de implementar, debugar e monitorar
- Caching via HTTP — GraphQL usa POST para queries (em teoria GET pode ser usado, mas é raro); CDNs e proxies não cacheam POST. Caching requer implementação no servidor (APQ + cache de resultado por hash)
- Uploads de arquivos — o protocolo GraphQL não tem suporte nativo a multipart; requer workarounds com
graphql-multipart-request-spec - Equipes pequenas sem tooling — DataLoader, complexity analysis, federation, persisted queries — a pilha de GraphQL produção-pronta é substancialmente mais complexa que REST
- Serviços de baixa latência crítica — o overhead de parsear e validar a query, resolver o plano de execução, e gerenciar DataLoaders adiciona latência comparado a um endpoint SQL direto
- Comunicação entre serviços internos — para service-to-service, gRPC com Protobuf é mais eficiente e oferece contrato mais rigoroso
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.
// 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].
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.
// 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
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.Exercícios práticos
- Implemente um schema GraphQL para um sistema de blog com tipos
Post,AuthoreComment. Adicione queries com paginação Relay Connections, mutationscreatePosteaddCommentcom payload de erros, e um DataLoader para evitar N+1 ao buscar autores de uma lista de posts. Critério: queryposts(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 retornarerrors: []em sucesso eerrors: [UserError]em falha sem alterar o HTTP status. - Reproduza o problema N+1 intencionalmente: crie um resolver de
authoremPostque 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). - Implemente query complexity analysis: atribua custo 1 para campos escalares e custo
n × custo_do_filhopara listas (onde n é o argumentofirst). Rejeite queries com complexidade total acima de 500. Critério: queryposts(first: 10) { title }(custo ≈10) é executada normalmente; queryposts(first: 100) { comments(first: 100) { text } }(custo 10.000) é rejeitada com erroQUERY_COMPLEXITY_EXCEEDEDantes de qualquer acesso ao banco. - Adicione uma Subscription
postPublishedque 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). - Desafio de federation: divida o schema de blog em dois subgraphs —
users-service(Author, autenticação) econtent-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
- spec GraphQL Specification (October 2021) — GraphQL Foundation.
- spec Relay Cursor Connections Specification — Meta / Relay Team.
- spec GraphQL over HTTP Specification — GraphQL Foundation (2023).
- artigo GraphQL: A Data Query Language — Lee Byron, Facebook (2015).
- livro Production Ready GraphQL — Marc-André Giroux (2020).
- docs Apollo Federation v2 — Apollo GraphQL.
- docs DataLoader v2 — The Guild / graphql/dataloader.
- artigo Principled GraphQL — Apollo GraphQL (2019).
- docs Hot Chocolate Documentation — ChilliCream.
- docs Strawberry GraphQL — Strawberry.
- docs gqlgen Documentation — 99designs.
- vídeo GraphQL: A Query Language for your API — Lee Byron, React Europe (2015).