GraphQL: Querytaal, Resolver en het N+1-probleem
GraphQL lost een van de fundamentele problemen van REST API's op: de klant heeft geen controle
welke gegevens hij ontvangt. Met RUST, GET /users/123 retourneert altijd alle gebruikersvelden,
zelfs als de klant er maar twee nodig heeft. Om een volledig gebruikersprofiel met bestellingen te uploaden
recent en geproduceerd, hebben vaak 3-4 afzonderlijke HTTP-aanroepen nodig. GraphQL elimineert dit probleem
waardoor de klant precies de benodigde gegevens in één enkele query kan specificeren.
Maar GraphQL introduceert zijn eigen complexiteit: de N+1 probleem en risico meer verraderlijke architectuur, in staat om een vraag die onschuldig lijkt, om te zetten in een lawine van tientallen of honderden databasequery's. In deze handleiding wordt uitgelegd hoe het werkt het oplossersysteem, hoe het N+1-probleem zich manifesteert, en hoe Gegevenslader lost dit op met automatische batching en caching.
Wat je gaat leren
- GraphQL-schema: typen, queries, mutaties en abonnementen
- De Solving Loop: hoe GraphQL een query uitvoert
- Resolver: Functies die gegevens voor elk veld leveren
- Het N+1-probleem: hoe het zich manifesteert en waarom het gevaarlijk is
- DataLoader: batching en caching om N+1 op te lossen
- Volledige installatie met Apollo Server en TypeScript
Het GraphQL-schema: het getypte contract
Elke GraphQL API begint met het schema: een contract dat alle beschikbare typen definieert, queries (reads), mutaties (writes) en abonnementen (real-time streams). Het schema is geschreven 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
}
De resolutielus: hoe GraphQL een query uitvoert
Wanneer een client een GraphQL-query verzendt, voert de server deze uit via een proces dat wordt genoemd "resolutie" die het schema als een boom doorkruist en de functie aanroept oplossers voor elk verplicht veld:
// 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
Implementeer Resolvers met Apollo Server
Een solver is een functie die weet hoe gegevens voor een specifiek veld moeten worden opgehaald. Elke oplosser ontvangt vier argumenten: de ouder (object van het ouderelement), de args (query-argumenten), de context (gedeelde gegevens zoals database en authenticatie) en info (metagegevens opvragen):
// 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' }
});
}
}
};
Probleem N+1: Het verborgen gevaar
Het N+1-probleem is het meest voorkomende architecturale risico in GraphQL. Stel je deze vraag voor:
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!
}
}
}
Deze "onschuldige" zoekopdracht levert 11 databasequery's op: 1 voor gebruikers + 1 voor elke gebruiker voor zijn bestellingen. Met 10 gebruikers zijn dat 11 zoekopdrachten. Met 100 gebruikers (als je de limiet verhoogt) zijn er 101. Met 1000 gebruikers op een beheerderspagina? 1001 zoekopdrachten. Het aantal zoekopdrachten is N (gebruikers) + 1 — van hier de naam "N+1 probleem".
// 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: de oplossing voor het N+1-probleem
Gegevenslader (gemaakt door Facebook, nu onderhouden door de community) lost het N+1 probleem met twee mechanismen: batchen (groepeert meerdere verzoeken in één enkele vraag) e cachen (resultaten hergebruiken voor dezelfde sleutel):
// 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
Met DataLoader: De query produceert 3 query's in plaats van 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!
Voltooi de Apollo Server-installatie met 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}`);
Dieptebeperking en complexiteitsanalyse
Met GraphQL kunnen klanten willekeurig diepgaande of complexe vragen stellen kan worden gebruikt voor DoS-aanvallen. Het is belangrijk om beschermingsmaatregelen te implementeren:
// 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`
})
]
});
GraphQL beste praktijken
- Gebruik altijd DataLoader voor relaties: voer nooit directe databasequery's uit in solvers van het type (User.orders, Order.items, enz.)
- Maak DataLoader voor verzoeken, niet voor globale aanvragen: Om dit te voorkomen moet de caching van DataLoader aan het einde van elk verzoek verlopen Autorisatieproblemen tussen verschillende gebruikers
- Implementeer dieptebeperking en complexiteitsanalyse: Zonder deze controles is GraphQL kwetsbaar voor DoS-aanvallen via complexe queries
- Aanhoudende query's gebruiken in productie: slaat vragen aan de clientzijde vooraf op en verzendt alleen de hash, waardoor vragen worden voorkomen willekeurig in de productie
- Schakel introspectie uit in de productie: introspectie stelt het hele plan bloot aan potentiële aanvallers
Conclusies en volgende stappen
GraphQL is een krachtig hulpmiddel voor API's die klanten met heterogene behoeften moeten bedienen, maar het N+1-probleem is reëel en vereist dat DataLoader correct wordt opgelost. Een API GraphQL in productie zonder DataLoader en zonder dieptebeperking en technisch onzeker en inefficiënt. Met de juiste configuratie biedt GraphQL met Apollo Server een uitstekende ontwikkelaarservaring en competitieve prestaties met REST.
Het volgende artikel onderzoekt GraphQL-federatie: zoals Apollo Router (geschreven in Rust) stelt meerdere onafhankelijke subgrafieken samen tot een verenigde supergrafiek, waardoor autonome teams om delen van het plan afzonderlijk te ontwikkelen.
Serie: API-ontwerp — REST, GraphQL, gRPC en tRPC vergeleken
- Artikel 1: Het API-landschap in 2026 – Beslissingsmatrix
- Artikel 2: REST in 2026 – Best Practice, versiebeheer en het Richardson Maturity Model
- Artikel 3 (dit): GraphQL — Querytaal, oplosser en het N+1-probleem
- Artikel 4: GraphQL Federatie — Supergraph, Subgraph en Apollo Router
- Artikel 5: gRPC — Protobuf, prestaties en service-to-service-communicatie
- Artikel 6: tRPC — Typeveiligheid end-to-end zonder codegeneratie
- Artikel 7: Webhooks — Patronen, beveiliging en logica voor opnieuw proberen
- Artikel 8: API-versiebeheer — URI, koptekst en beëindigingsbeleid
- Artikel 9: Snelheidsbeperking en -beperking — Algoritmen en implementaties
- Artikel 10: Hybride API-architectuur — REST + tRPC + gRPC in 2026







