GraphQL: Limbajul de interogare, Resolver și problema N+1
GraphQL rezolvă una dintre problemele fundamentale ale API-urilor REST: clientul nu poate controla
ce date primeste. Cu REST, GET /users/123 returnează întotdeauna toate câmpurile utilizatorului,
chiar dacă clientul are nevoie doar de două. Pentru a încărca un profil de utilizator complet cu comenzi
recente și produse, adesea au nevoie de 3-4 apeluri HTTP separate. GraphQL elimină această problemă
permițând clientului să specifice exact datele necesare într-o singură interogare.
Dar GraphQL introduce propria sa complexitate: the N+1 problemă si risc arhitectural mai insidios, capabil să transforme o interogare care pare inofensivă într-un avalanșă de zeci sau sute de interogări de baze de date. Acest ghid explică cum funcționează sistemul de rezolvare, cum se manifestă problema N+1 și cum DataLoader rezolvă acest lucru prin loturi și stocare în cache automate.
Ce vei învăța
- Schema GraphQL: tipuri, interogări, mutații și abonamente
- Bucla de rezolvare: cum execută GraphQL o interogare
- Resolver: Funcții care furnizează date pentru fiecare câmp
- Problema N+1: cum se manifestă și de ce este periculos
- DataLoader: loturi și stocare în cache pentru a rezolva N+1
- Configurare completă cu Apollo Server și TypeScript
Schema GraphQL: Contractul tipizat
Fiecare API GraphQL începe cu schema: un contract care definește toate tipurile disponibile, interogări (citire), mutații (scriere) și abonamente (fluxuri în timp real). Schema este scrisă în 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
}
Bucla de rezoluție: cum GraphQL execută o interogare
Când un client trimite o interogare GraphQL, serverul o execută printr-un proces numit „rezoluție” care străbate schema ca un arbore, apelând funcția rezolutori pentru fiecare câmp obligatoriu:
// 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
Implementați soluții cu Apollo Server
Un resolver este o funcție care știe cum să extragă date pentru un anumit câmp. Fiecare rezolutor primește patru argumente: părintele (obiectul elementului părinte), argumentele (argumente de interogare), contextul (date partajate, cum ar fi baza de date și autentificare) și informații (metadatele interogării):
// 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' }
});
}
}
};
Problema N+1: Pericolul ascuns
Problema N+1 este cel mai frecvent risc arhitectural în GraphQL. Imaginează-ți această interogare:
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!
}
}
}
Această interogare „inocentă” produce 11 interogări de bază de date: 1 pentru utilizatori + 1 pentru fiecare utilizator pentru ordinele lui. Cu 10 utilizatori, înseamnă 11 interogări. Cu 100 de utilizatori (dacă creșteți limita) sunt 101. Cu 1000 de utilizatori pe o pagină de administrare? 1001 de interogări. Numărul de interogări este N (utilizatori) + 1 — de la aici numele „N+1 problem”.
// 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: Soluția la problema N+1
DataLoader (creat de Facebook, întreținut acum de comunitate) rezolvă problema N+1 problemă cu două mecanisme: loturi (grupează cereri multiple într-una singură o singură interogare) e stocarea în cache (reutilizați rezultatele pentru aceeași cheie):
// 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
Cu DataLoader: Interogarea produce 3 interogări în loc de 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!
Finalizați configurarea Apollo Server cu 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}`);
Limitarea adâncimii și analiza complexității
GraphQL permite clienților să facă interogări arbitrar profunde sau complexe, care poate fi folosit pentru atacuri DoS. Este important să implementați protecții:
// 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`
})
]
});
Cele mai bune practici GraphQL
- Utilizați întotdeauna DataLoader pentru relații: nu faceți niciodată interogări directe la baza de date în soluții de tip (User.orders, Order.items etc.)
- Creați DataLoader pentru cereri, nu globale: Memorarea în cache DataLoader trebuie să expire la sfârșitul fiecărei solicitări pentru a evita Probleme de autorizare între diferiți utilizatori
- Implementați limitarea adâncimii și analiza complexității: Fără aceste verificări, GraphQL este vulnerabil la atacuri DoS prin interogări complexe
- Utilizați interogări persistente în producție: salvează interogările la nivelul clientului în avans și trimite doar hash-ul, prevenind interogările arbitrar în producție
- Dezactivați introspecția în producție: introspecția expune întreaga schemă potențialilor atacatori
Concluzii și pașii următori
GraphQL este un instrument puternic pentru API-urile care trebuie să servească clienții cu nevoi eterogene, dar problema N+1 este reală și necesită ca DataLoader să o rezolve corect. Un API GraphQL în producție fără DataLoader și fără limitare de adâncime și nesigur din punct de vedere tehnic si ineficienta. Cu configurația corectă, GraphQL cu Apollo Server oferă o experiență excelentă de dezvoltator și performanță competitivă cu REST.
Următorul articol explorează Federația GraphQL: ca Apollo Router (scris în Rust) compune mai multe subgrafe independente într-un supergraf unificat, permițând echipe autonome pentru a dezvolta părți ale schemei în mod izolat.
Seria: API Design — Comparație REST, GraphQL, gRPC și tRPC
- Articolul 1: Peisajul API în 2026 — Matricea decizionale
- Articolul 2: REST în 2026 — Cele mai bune practici, versiunea și modelul de maturitate Richardson
- Articolul 3 (acesta): GraphQL — Limbajul de interogare, Resolver și problema N+1
- Articolul 4: Federația GraphQL – Supergraph, Subgraph și Apollo Router
- Articolul 5: gRPC — Protobuf, performanță și comunicare de la serviciu la serviciu
- Articolul 6: tRPC — Tip Siguranță End-to-End fără generare de cod
- Articolul 7: Webhooks — Modele, securitate și logică de reîncercare
- Articolul 8: Versiune API - URI, antet și Politica de depreciere
- Articolul 9: Limitarea ratei și limitarea — Algoritmi și implementări
- Articolul 10: Arhitectură API hibridă – REST + tRPC + gRPC în 2026







