chema Definition Language) to decouple the API contract from implementation. Use @graphql-codegen to generate TypeScript types, ensuring resolvers remain type-safe.
# schema.graphql
type Query {
user(id: ID!): User
users(limit: Int = 10, offset: Int = 0): [User!]!
}
type User {
id: ID!
email: String!
orders: [Order!]!
}
type Order {
id: ID!
total: Float!
items: [OrderItem!]!
}
type OrderItem {
id: ID!
productName: String!
}
2. Resolver Architecture with DataLoader
Resolvers must never execute direct database queries for relational data. Instead, they must use DataLoader to batch requests. Crucially, DataLoader instances must be created per-request to prevent cross-request data leakage and ensure cache isolation.
import { createYoga } from 'graphql-yoga';
import DataLoader from 'dataloader';
import { PrismaClient } from '@prisma/client';
const prisma = new PrismaClient();
// Factory functions for batch loading
const createBatchLoaders = () => ({
ordersByUserId: new DataLoader(async (userIds: readonly string[]) => {
const orders = await prisma.order.findMany({
where: { userId: { in: userIds as string[] } }
});
// Map results back to the order of input keys
return userIds.map(id => orders.filter(o => o.userId === id));
}),
itemsByOrderId: new DataLoader(async (orderIds: readonly string[]) => {
const items = await prisma.orderItem.findMany({
where: { orderId: { in: orderIds as string[] } }
});
return orderIds.map(id => items.filter(i => i.orderId === id));
})
});
// Resolvers
const resolvers = {
Query: {
user: (_, { id }, context) => context.prisma.user.findUnique({ where: { id } }),
},
User: {
orders: async (parent, _, context) => {
return context.loaders.ordersByUserId.load(parent.id);
},
},
Order: {
items: async (parent, _, context) => {
return context.loaders.itemsByOrderId.load(parent.id);
},
},
};
3. Context and Server Configuration
The server configuration must inject the context (Prisma client and DataLoaders) and apply essential plugins for security and observability.
import { useDepthLimit, usePersistedQueries, useGraphQlJit } from 'graphql-yoga';
const yoga = createYoga({
schema: /* generated schema */,
resolvers,
context: () => ({
prisma,
loaders: createBatchLoaders(), // Fresh loaders per request
}),
plugins: [
// Security: Limit query depth to prevent DoS
useDepthLimit(7),
// Performance: JIT compilation for faster execution
useGraphQlJit(),
// Security: Persisted queries to mitigate injection risks
usePersistedQueries({ ttl: 3600 }),
],
graphqlEndpoint: '/graphql',
graphiql: process.env.NODE_ENV === 'development',
});
export { yoga };
4. Error Handling and Extensions
Production servers must mask internal errors while providing actionable feedback to clients. Use the extensions field to pass error codes without leaking stack traces.
// Error masking plugin logic
const formatError = (error) => {
if (process.env.NODE_ENV === 'production') {
return {
message: error.message,
extensions: {
code: error.extensions?.code || 'INTERNAL_SERVER_ERROR',
// Omit stack trace and internal details
},
};
}
return error;
};
Pitfall Guide
1. N+1 Query Explosion
Mistake: Writing resolvers that fetch data individually for each parent object.
Impact: A query requesting 100 users with 10 orders each triggers 1,001 database queries.
Remediation: Implement DataLoader for all relational fetches. Ensure batching functions accept arrays of keys and return arrays of results in the same order.
2. Cross-Request Data Leakage
Mistake: Instantiating DataLoader outside the request context or sharing instances across requests.
Impact: User A's data may be returned to User B due to cache poisoning.
Remediation: Always instantiate DataLoader factories inside the context function. Each request must receive a fresh set of loaders.
3. Uncontrolled Query Complexity
Mistake: Failing to limit query depth or complexity.
Impact: Attackers can craft queries that cause exponential execution time, exhausting CPU resources.
Remediation: Implement useDepthLimit and a complexity analysis plugin. Define cost weights for fields based on database load.
4. Introspection in Production
Mistake: Leaving introspection enabled in production environments.
Impact: Attackers can map the entire schema, revealing internal types, deprecated fields, and potential attack vectors.
Remediation: Disable introspection in production. Allow introspection only for authorized internal tooling or via a separate admin endpoint.
5. Resolver Side Effects
Mistake: Performing mutations or state changes within query resolvers.
Impact: Queries may be cached aggressively, causing side effects to be skipped or replayed unexpectedly.
Remediation: Strictly separate Queries and Mutations. Queries must be idempotent and free of side effects.
6. Inconsistent Error Handling
Mistake: Returning null for errors or mixing error formats.
Impact: Clients cannot reliably distinguish between missing data and failures.
Remediation: Use the errors array in the GraphQL response. Return null only for legitimately missing data. Use custom error classes with extension codes.
7. Ignoring Cache Invalidation
Mistake: Implementing response caching without a strategy for invalidation.
Impact: Clients receive stale data after mutations.
Remediation: Use field-level caching with explicit invalidation rules or leverage CDN caching with Cache-Control headers tied to query hashes.
Production Bundle
Action Checklist
Decision Matrix
| Scenario | Recommended Approach | Why | Cost Impact |
|---|
| Monolith Backend | Single GraphQL Schema | Simplifies development, reduces network hops, easier caching. | Low infrastructure cost; moderate dev complexity. |
| Microservices | Schema Federation / Composable Graph | Allows teams to own subgraphs; decouples deployment cycles. | High infrastructure cost; requires gateway management. |
| High Read / Low Write | Response Caching + DataLoader | Maximizes throughput; reduces database load significantly. | Low DB cost; increased memory for cache. |
| High Write / Consistency Critical | No Response Cache; DataLoader only | Ensures data freshness; batching optimizes read-after-write. | Higher DB cost; requires robust indexing. |
| Public API | Persisted Queries + Strict Limits | Mitigates injection risks; controls resource consumption. | Low risk; requires client-side APQ support. |
Configuration Template
Copy this configuration for a secure, performant graphql-yoga setup with TypeScript.
import { createYoga } from 'graphql-yoga';
import { useDepthLimit, usePersistedQueries, useGraphQlJit, useSchema } from 'graphql-yoga';
import DataLoader from 'dataloader';
import { PrismaClient } from '@prisma/client';
const prisma = new PrismaClient();
const createLoaders = () => ({
// Define batch loaders here
userById: new DataLoader(async (ids) => {
const users = await prisma.user.findMany({ where: { id: { in: ids as string[] } } });
return ids.map(id => users.find(u => u.id === id));
}),
});
export const yoga = createYoga({
schema: /* import your schema */,
resolvers: /* import your resolvers */,
context: () => ({
prisma,
loaders: createLoaders(),
}),
plugins: [
useDepthLimit(7),
usePersistedQueries({ ttl: 3600 }),
useGraphQlJit(),
// Custom error formatting
{
onExecute: ({ result }) => {
if (result.errors && process.env.NODE_ENV === 'production') {
result.errors = result.errors.map(err => ({
message: err.message,
extensions: { code: err.extensions?.code || 'ERROR' },
}));
}
},
},
],
// Disable introspection in prod
introspection: process.env.NODE_ENV !== 'production',
graphqlEndpoint: '/api/graphql',
// CORS configuration for production
cors: {
origin: process.env.ALLOWED_ORIGINS?.split(',') || ['http://localhost:3000'],
credentials: true,
},
});
Quick Start Guide
-
Initialize Project:
npm create graphql-yoga@latest my-graphql-server
cd my-graphql-server
npm install dataloader @prisma/client
-
Define Schema and Generate Types:
Create schema.graphql with your types. Run npx graphql-codegen to generate TypeScript interfaces for resolvers.
-
Implement Resolvers with DataLoader:
Update resolvers.ts. Import DataLoader. Create a createLoaders function. Inject loaders via the context function in yoga.ts. Replace direct DB calls with context.loaders.xyz.load(id).
-
Apply Security Plugins:
In yoga.ts, add useDepthLimit(7) and usePersistedQueries() to the plugins array. Set introspection: false for production builds.
-
Run and Verify:
npm run dev
Execute a nested query in GraphiQL. Monitor your database logs to confirm that queries are batched and the N+1 pattern is eliminated. Verify that dataLoader instances are not shared across requests.