GraphQL: Query Language, Resolver e il Problema N+1
GraphQL risolve uno dei problemi fondamentali delle REST API: il client non puo controllare
quali dati riceve. Con REST, GET /users/123 ritorna sempre tutti i campi dell'utente,
anche se il client ne ha bisogno solo di due. Per caricare un profilo utente completo con ordini
recenti e prodotti, spesso servono 3-4 chiamate HTTP separate. GraphQL elimina questo problema
permettendo al client di specificare esattamente i dati necessari in una singola query.
Ma GraphQL introduce la sua complessita: il problema N+1 e il rischio architetturale piu insidioso, capace di trasformare una query che sembra innocua in una valanga di decine o centinaia di query al database. Questa guida spiega come funziona il sistema di resolver, come il problema N+1 si manifesta, e come DataLoader lo risolve con batching e caching automatico.
Cosa Imparerai
- Schema GraphQL: tipi, query, mutation e subscription
- Il ciclo di risoluzione: come GraphQL esegue una query
- Resolver: funzioni che forniscono i dati per ogni campo
- Il problema N+1: come si manifesta e perche e pericoloso
- DataLoader: batching e caching per risolvere N+1
- Setup completo con Apollo Server e TypeScript
Lo Schema GraphQL: Il Contratto Tipizzato
Ogni API GraphQL inizia con lo schema: un contratto che definisce tutti i tipi disponibili, le query (letture), le mutation (scritture) e le subscription (stream real-time). Lo schema e scritto in SDL (Schema Definition Language):
// schema.graphql
type User {
id: ID!
name: String!
email: String!
createdAt: String!
orders: [Order!]! # Un utente ha molti ordini
profile: UserProfile # Nullable: non tutti gli utenti hanno un profilo
}
type Order {
id: ID!
total: Float!
status: OrderStatus!
createdAt: String!
items: [OrderItem!]! # Un ordine ha molti item
user: User! # Relazione bidirezionale
}
type OrderItem {
id: ID!
quantity: Int!
price: Float!
product: Product! # L'item ha un prodotto
}
type Product {
id: ID!
name: String!
price: Float!
category: String!
}
type UserProfile {
bio: String
avatarUrl: String
website: String
}
enum OrderStatus {
PENDING
CONFIRMED
SHIPPED
DELIVERED
CANCELLED
}
# Query: operazioni di lettura (equivalente GET in REST)
type Query {
user(id: ID!): User
users(limit: Int = 20, offset: Int = 0): [User!]!
order(id: ID!): Order
searchProducts(query: String!, limit: Int = 10): [Product!]!
}
# Mutation: operazioni di scrittura (equivalente POST/PUT/DELETE)
type Mutation {
createUser(input: CreateUserInput!): User!
updateUser(id: ID!, input: UpdateUserInput!): User!
deleteUser(id: ID!): Boolean!
createOrder(input: CreateOrderInput!): Order!
}
# Subscription: stream real-time (WebSocket)
type Subscription {
orderStatusChanged(orderId: ID!): Order!
newUserRegistered: User!
}
input CreateUserInput {
name: String!
email: String!
password: String!
}
input UpdateUserInput {
name: String
email: String
}
Il Ciclo di Risoluzione: Come GraphQL Esegue una Query
Quando un client invia una query GraphQL, il server la esegue attraverso un processo chiamato "risoluzione" che attraversa lo schema come un albero, chiamando la funzione resolver per ogni campo richiesto:
// Questa query del client:
query {
users(limit: 5) {
id
name
orders {
id
total
status
}
}
}
// Produce questo albero di risoluzione:
// 1. Query.users -> chiama il resolver "users"
// 2. Per ogni User restituito:
// - User.id -> valore direttamente dall'oggetto
// - User.name -> valore direttamente dall'oggetto
// - User.orders -> chiama il resolver "orders" con parent = user
// 3. Per ogni Order dentro ogni User:
// - Order.id -> valore direttamente dall'oggetto
// - Order.total -> valore direttamente dall'oggetto
// - Order.status -> valore direttamente dall'oggetto
Implementare i Resolver con Apollo Server
Un resolver e una funzione che sa come recuperare i dati per un campo specifico. Ogni resolver riceve quattro argomenti: il parent (oggetto dell'elemento padre), gli args (argomenti della query), il context (dati condivisi come database e autenticazione), e info (metadati della query):
// src/resolvers/index.ts
import { Resolvers } from './generated/types'; // Tipi generati da GraphQL Code Generator
import DataLoader from 'dataloader';
// Context condiviso tra tutti i resolver
export interface GraphQLContext {
db: DatabaseClient;
currentUser: User | null;
loaders: {
userById: DataLoader<string, User>;
ordersByUserId: DataLoader<string, Order[]>;
productById: DataLoader<string, Product>;
};
}
export const resolvers: Resolvers<GraphQLContext> = {
Query: {
// parent = {} (root query), args = { limit, offset }, ctx = context
users: async (_, { limit = 20, offset = 0 }, { db, currentUser }) => {
if (!currentUser) throw new GraphQLError('Authentication required', {
extensions: { code: 'UNAUTHENTICATED' }
});
return db.users.findMany({
take: Math.min(limit, 100), // Limite massimo di sicurezza
skip: offset,
orderBy: { createdAt: 'desc' }
});
},
user: async (_, { id }, { db }) => {
return db.users.findUnique({ where: { id } });
},
searchProducts: async (_, { query, limit }, { db }) => {
return db.products.findMany({
where: {
OR: [
{ name: { contains: query, mode: 'insensitive' } },
{ category: { contains: query, mode: 'insensitive' } }
]
},
take: Math.min(limit, 50)
});
}
},
// Resolver per i campi del tipo User
User: {
// parent = User object, args = {}, ctx = context
orders: async (user, _, { loaders }) => {
// USA DataLoader invece di db.orders.findMany({ where: { userId: user.id } })
// Spiegazione nella sezione successiva!
return loaders.ordersByUserId.load(user.id);
},
profile: async (user, _, { db }) => {
return db.userProfiles.findUnique({ where: { userId: user.id } });
}
},
// Resolver per i campi del tipo Order
Order: {
items: async (order, _, { db }) => {
return db.orderItems.findMany({ where: { orderId: order.id } });
},
user: async (order, _, { loaders }) => {
return loaders.userById.load(order.userId);
}
},
// Resolver per i campi del tipo OrderItem
OrderItem: {
product: async (item, _, { loaders }) => {
return loaders.productById.load(item.productId);
}
},
Mutation: {
createUser: async (_, { input }, { db }) => {
const hashedPassword = await bcrypt.hash(input.password, 12);
return db.users.create({
data: { ...input, password: hashedPassword }
});
},
createOrder: async (_, { input }, { db, currentUser }) => {
if (!currentUser) throw new GraphQLError('Authentication required');
return db.orders.create({
data: { ...input, userId: currentUser.id, status: 'PENDING' }
});
}
}
};
Il Problema N+1: Il Pericolo Nascosto
Il problema N+1 e il rischio architetturale piu comune in GraphQL. Immagina questa query:
query {
users(limit: 10) { # 1 query: SELECT * FROM users LIMIT 10
name
orders { # 10 query: SELECT * FROM orders WHERE userId = ?
total # Una per ogni utente!
}
}
}
Questa query "innocente" produce 11 query al database: 1 per gli utenti + 1 per ogni utente per i suoi ordini. Con 10 utenti sono 11 query. Con 100 utenti (se aumenti il limit) sono 101. Con 1000 utenti in una pagina admin? 1001 query. Il numero di query e N (utenti) + 1 — da qui il nome "problema N+1".
// SENZA DataLoader: N+1 in azione
const User_orders = async (user, _, { db }) => {
// Questa riga viene chiamata UNA VOLTA PER OGNI UTENTE nella lista
return db.orders.findMany({ where: { userId: user.id } });
// Con 10 utenti = 10 query separate al database
// SELECT * FROM orders WHERE userId = '1'
// SELECT * FROM orders WHERE userId = '2'
// ... (10 volte!)
};
// SENZA DataLoader: prodotti per ogni item di ogni ordine
const OrderItem_product = async (item, _, { db }) => {
return db.products.findUnique({ where: { id: item.productId } });
// Se hai 10 utenti x 5 ordini x 3 items = 150 query solo per i prodotti!
};
DataLoader: La Soluzione al Problema N+1
DataLoader (creato da Facebook, ora mantenuto dalla community) risolve il problema N+1 con due meccanismi: batching (raggruppa piu richieste in una singola query) e caching (riusa i risultati per la stessa chiave):
// src/loaders/index.ts
import DataLoader from 'dataloader';
import { DatabaseClient } from './db';
export function createLoaders(db: DatabaseClient) {
return {
// Batch function: riceve un ARRAY di chiavi, ritorna un ARRAY di risultati
// L'ordine dei risultati DEVE corrispondere all'ordine delle chiavi
userById: new DataLoader<string, User | null>(
async (userIds) => {
// UNA SOLA QUERY per tutti gli ID! (invece di N query)
const users = await db.users.findMany({
where: { id: { in: [...userIds] } }
});
// Mappa risultati mantenendo l'ordine originale delle chiavi
const userMap = new Map(users.map(u => [u.id, u]));
return userIds.map(id => userMap.get(id) ?? null);
}
),
ordersByUserId: new DataLoader<string, Order[]>(
async (userIds) => {
// UNA SOLA QUERY invece di N query
const orders = await db.orders.findMany({
where: { userId: { in: [...userIds] } }
});
// Raggruppa per userId
const ordersByUser = new Map<string, Order[]>();
for (const order of orders) {
const existing = ordersByUser.get(order.userId) ?? [];
ordersByUser.set(order.userId, [...existing, order]);
}
return userIds.map(id => ordersByUser.get(id) ?? []);
},
{
// Ottimizzazione: raggruppa le chiavi duplicate
cacheKeyFn: (key) => key,
// Massimo 100 chiavi per batch (evita query enormi)
maxBatchSize: 100
}
),
productById: new DataLoader<string, Product | null>(
async (productIds) => {
const products = await db.products.findMany({
where: { id: { in: [...productIds] } }
});
const productMap = new Map(products.map(p => [p.id, p]));
return productIds.map(id => productMap.get(id) ?? null);
},
{
// Cache per tutta la durata della request (default)
cache: true
}
)
};
}
// Come funziona DataLoader internamente:
// 1. Il resolver chiama loader.load('userId-1') e loader.load('userId-2')
// 2. DataLoader aspetta il "tick" successivo di JavaScript
// 3. Raggruppa tutte le chiamate .load() accumulate in quel tick
// 4. Chiama la batch function UNA SOLA VOLTA con ['userId-1', 'userId-2']
// 5. Distribuisce i risultati ai chiamanti originali
Con DataLoader: La Query Produce 3 Query invece di 111
// Query: 10 utenti con 5 ordini ciascuno, ogni ordine con 3 prodotti
// SENZA DataLoader: 1 + 10 + 50 + 150 = 211 query database
// SENZA DataLoader con 100 utenti: 1 + 100 + 500 + 1500 = 2101 query!
// CON DataLoader:
// 1 query: SELECT * FROM users LIMIT 10
// 1 query: SELECT * FROM orders WHERE userId IN (1,2,3,...,10)
// 1 query: SELECT * FROM products WHERE id IN (product-id-1, ..., product-id-15)
// = 3 query totali, indipendentemente da quanti utenti/ordini/prodotti!
Setup Completo Apollo Server con TypeScript
// src/server.ts
import { ApolloServer } from '@apollo/server';
import { startStandaloneServer } from '@apollo/server/standalone';
import { readFileSync } from 'fs';
import { resolvers } from './resolvers';
import { createLoaders } from './loaders';
import { GraphQLContext } from './types';
import { db } from './db';
import { authenticateRequest } from './auth';
const typeDefs = readFileSync('./schema.graphql', 'utf8');
const server = new ApolloServer<GraphQLContext>({
typeDefs,
resolvers,
// Plugin per logging e monitoring
plugins: [
// Log ogni richiesta in production
{
requestDidStart: async () => ({
didResolveOperation: async ({ request, document }) => {
console.log('GraphQL operation:', request.operationName);
}
})
}
],
// Limiti di sicurezza
introspection: process.env.NODE_ENV !== 'production'
});
const { url } = await startStandaloneServer(server, {
listen: { port: 4000 },
context: async ({ req }) => {
// I DataLoader devono essere creati PER REQUEST (non globali!)
// Ogni request ha il suo scope di caching
return {
db,
currentUser: await authenticateRequest(req.headers.authorization),
loaders: createLoaders(db)
// IMPORTANTE: crea nuovi loaders per ogni request
// (altrimenti il cache persiste tra requests e puo causare
// problemi di autorizzazione)
};
}
});
console.log(`GraphQL server running at: ${url}`);
Depth Limiting e Complexity Analysis
GraphQL permette ai client di fare query arbitrariamente profonde o complesse, il che puo essere usato per attacchi DoS. E importante implementare protezioni:
// Protezione con graphql-depth-limit e graphql-query-complexity
import depthLimit from 'graphql-depth-limit';
import { createComplexityLimitRule } from 'graphql-validation-complexity';
const server = new ApolloServer({
typeDefs,
resolvers,
validationRules: [
// Massima profondita della query: previene query tipo user.orders.items.product.vendor.orders.items...
depthLimit(7),
// Massima complessita calcolata
createComplexityLimitRule(1000, {
onCost: (cost) => console.log('Query complexity:', cost),
formatErrorMessage: (cost) =>
`Query too complex (${cost}). Maximum complexity: 1000`
})
]
});
Best Practice GraphQL
- Sempre usa DataLoader per relazioni: non fare mai query database dirette nei resolver di tipo (User.orders, Order.items, etc.)
- Crea DataLoader per request, non globali: il caching dei DataLoader deve scadere alla fine di ogni request per evitare problemi di autorizzazione tra utenti diversi
- Implementa depth limiting e complexity analysis: senza questi controlli, GraphQL e vulnerabile ad attacchi DoS tramite query complesse
- Usa persisted queries in produzione: salva le query client-side in anticipo e invia solo l'hash, prevenendo query arbitrarie in produzione
- Disabilita introspection in produzione: l'introspection espone l'intero schema a potenziali attaccanti
Conclusioni e Prossimi Passi
GraphQL e uno strumento potente per API che devono servire client con bisogni eterogenei, ma il problema N+1 e reale e richiede DataLoader per essere risolto correttamente. Un'API GraphQL in produzione senza DataLoader e senza depth limiting e tecnicamente insicura e inefficiente. Con la configurazione corretta, GraphQL con Apollo Server offre una developer experience eccellente e performance competitive con REST.
Il prossimo articolo esplora GraphQL Federation: come Apollo Router (scritto in Rust) compone piu subgraph indipendenti in un supergraph unificato, abilitando team autonomi di sviluppare parti dello schema in isolamento.
Serie: API Design — REST, GraphQL, gRPC e tRPC a Confronto
- Articolo 1: Il Panorama delle API nel 2026 — Matrice Decisionale
- Articolo 2: REST nel 2026 — Best Practice, Versioning e Richardson Maturity Model
- Articolo 3 (questo): GraphQL — Query Language, Resolver e il Problema N+1
- Articolo 4: GraphQL Federation — Supergraph, Subgraph e Apollo Router
- Articolo 5: gRPC — Protobuf, Performance e Comunicazione Service-to-Service
- Articolo 6: tRPC — Type Safety End-to-End senza Code Generation
- Articolo 7: Webhooks — Pattern, Sicurezza e Retry Logic
- Articolo 8: API Versioning — URI, Header e Deprecation Policy
- Articolo 9: Rate Limiting e Throttling — Algoritmi e Implementazioni
- Articolo 10: Architettura API Ibrida — REST + tRPC + gRPC nel 2026







