Actionable Guideline Rules for designing, writing, and operating highly-performant GraphQL APIs with Apollo Server, TypeScript, DataLoader, and comprehensive monitoring.
Your GraphQL API is drowning in database queries. You write what looks like a clean, efficient resolver only to watch it spawn hundreds of database calls for a single client request. The worst part? Your monitoring shows the symptoms but not the solution.
GraphQL's flexibility creates a hidden trap. Unlike REST where you control exactly what data each endpoint returns, GraphQL lets clients request any combination of fields, creating unpredictable query patterns that can cripple your API:
These Cursor Rules transform your Apollo Server into a high-performance GraphQL engine that anticipates problems before they hit production. You'll write resolvers that automatically batch queries, enforce complexity limits, and serve cached data intelligently.
Instead of reactive debugging, you'll build proactive performance directly into your development workflow.
// Typical resolver that creates N+1 queries
export const getUserPosts = async (parent, args, ctx) => {
const user = await ctx.db.users.findById(parent.userId);
const posts = await ctx.db.posts.findByUserId(user.id); // N+1 problem
return posts;
};
// Optimized resolver with DataLoader
export const getUserPosts = async (parent, args, ctx): Promise<Post[]> => {
if (!ctx.user) throw new AuthenticationError('Unauthenticated');
if (args.limit > 1000) throw new UserInputError('limit too large');
return ctx.loaders.userPosts.load(parent.id);
};
// DataLoader automatically batches and caches
const userPostsLoader = new DataLoader(async (userIds: string[]) => {
const posts = await db.posts.findByUserIds(userIds); // Single query
return userIds.map(id => posts.filter(post => post.userId === id));
});
// Automatic query analysis and rejection
const server = new ApolloServer({
plugins: [
ApolloServerPluginQueryComplexity({
maxDepth: 8,
maxCost: 10_000,
estimators: [
fieldExtensionsEstimator(),
simpleEstimator({ defaultComplexity: 1 })
]
})
]
});
// Automatic resolver tracing
const resolverTrace = {
requestDidStart() {
return {
willSendResponse(requestContext) {
const span = trace.getActiveSpan();
span?.setAttributes({
'graphql.operation.name': requestContext.operationName,
'graphql.resolver.duration': Date.now() - requestContext.request.startTime
});
}
};
}
};
.cursorrules file// Create entity loaders
export const createLoaders = (db: Database) => ({
users: new DataLoader(async (ids: string[]) => {
const users = await db.users.findByIds(ids);
return ids.map(id => users.find(user => user.id === id) || null);
}),
userPosts: new DataLoader(async (userIds: string[]) => {
const posts = await db.posts.findByUserIds(userIds);
return userIds.map(id => posts.filter(post => post.userId === id));
})
});
// Inject into context
const context = ({ req }) => ({
user: req.user,
loaders: createLoaders(db)
});
import { createComplexityLimitRule } from 'graphql-query-complexity';
const server = new ApolloServer({
validationRules: [createComplexityLimitRule(10000)]
});
import { trace } from '@opentelemetry/api';
const performancePlugin = {
requestDidStart() {
return {
didResolveField({ info }) {
const span = trace.getActiveSpan();
span?.setAttributes({
'graphql.field.name': info.fieldName,
'graphql.field.type': info.returnType.toString()
});
}
};
}
};
Transform your query patterns from exponential to linear:
Real-world performance gains:
Your GraphQL API will handle 10x more traffic while using fewer resources. More importantly, you'll ship features faster because performance optimization happens automatically as you code.
Stop fighting GraphQL's complexity. Start building APIs that scale by design.
You are an expert in GraphQL, TypeScript (Node.js), Apollo Server 4, DataLoader, OpenTelemetry, Redis, and HTTP/2.
Key Principles
- Always request the minimum set of fields needed by the client.
- Model the schema around client use-cases, not database tables.
- Prefer single-purpose queries/mutations to catch-all operations.
- Keep resolver chains shallow; avoid more than 3 resolver hops.
- Normalize and cache entities using the tuple `__typename:id`.
- Paginate every list field that can exceed 100 items (Relay Connection or Offset style).
- Fail fast: validate input, check auth, then short-circuit on errors.
- Measure everything; optimise only with real trace data.
TypeScript / JavaScript
- Use `export const <name> = (parent, args, ctx): Promise<ReturnType>` for every resolver.
- Never access `req`/`res` directly inside resolvers; use the injected `context` object.
- Always mark async resolvers with `async` and return awaited results (no implicit Promise).
- Use `enum` for constant strings exposed in the schema; avoid magic strings.
- Treat `null` and `undefined` differently: return `null` for absent GraphQL fields, never `undefined`.
- Keep resolver files ≤200 LOC; split into `resolver.ts`, `loader.ts`, `types.ts`.
- Place happy path last; guard clauses first:
```ts
if (!ctx.user) throw new AuthenticationError('Unauthenticated');
if (args.limit > 1000) throw new UserInputError('limit too large');
// happy path
return postsService.list(args);
```
Error Handling and Validation
- Reject queries exceeding `maxDepth=8` or `maxCost=10_000` using `graphql-query-complexity`.
- Wrap every resolver with `try/catch`; throw `ApolloError` or subclasses only.
- Surface user-safe messages; hide internal errors via `formatError` hook.
- Validate `args` with Zod/Yup before hitting downstream services.
- Implement global rate limiting per caller token/IP at the gateway (e.g., 600 queries / 10 min).
- Invalidate caches via TTL (≤10 min) or subscription-driven events; never serve stale data blind.
Apollo Server Rules
- Use Apollo Server 4 `@apollo/server` with a single landing middleware in Express/Fastify.
- Enable `plugins: [ApolloServerPluginDrainHttpServer(), ApolloServerPluginLandingPageDisabled()]` in prod.
- Register `ApolloServerPluginUsageReporting` only in staging/prod with a sampling rate ≤0.1.
- Implement persisted queries (`apollo-server-plugin-persisted-queries`) and deny non-persisted in prod.
- Use `plugin.requestDidStart` to push `traceparent` header into OpenTelemetry span.
DataLoader Rules
- One loader per entity/table; share the instance via `context` per request.
- Batch size ≤500 and `cacheKeyFn` = `(key) => key.toString()` for consistent caching.
- Do NOT call loaders from inside other loaders (avoids dead-loops).
- Clear/invalidate loader cache after relevant mutations.
Caching & Performance
- Layered cache strategy:
1. CDN (HTTP) – persisted queries with long max-age, immutable.
2. Edge KV/Redis – entity cache keyed by `__typename:id` (TTL 60–300 s).
3. DataLoader – per-request in-process cache.
- Enable GZIP or Brotli (>95 % coverage) and HTTP/2 multiplexing.
- Compress large lists/JSON fields in DB with `lz4` to reduce I/O when feasible.
Testing & Monitoring
- Unit: jest + `@graphql-tools/mock` for schema-level tests.
- Integration: run `graphql-rate-limit` + `graphql-query-complexity` under k6 to ensure limits.
- Snapshot query plans with `apollo queryplan`. Failing tests if plan changes unexpectedly.
- Instrument OpenTelemetry auto-trace for every resolver; attach attributes: `fieldName`, `typename`, `durationMs`.
- Alert on p95 resolver >50 ms or cache hit-rate <80 %.
Security
- Enforce query depth/complexity limits (see Error Handling).
- Deny introspection in production unless `X-GraphQL-Auth-Admin` header present.
- Sanitize user input; never directly embed args into SQL. Use parameterised queries/ORM.
- Strip `__schema` and `__type` fields from persisted queries list.
Schema Design Conventions
- Use SDL comments (`"""` triple quotes) for field docs; keep within 90 chars
- `ID!` for IDs; avoid string IDs.
- Use custom scalars (`DateTime`, `Email`, `URL`, `UUID`). Implement validation in scalar `parseValue`.
- Name mutations verb-noun: `updateUserEmail`, `archivePost`.
- Deprecate fields with `@deprecated(reason: "Use newField")`; remove after two minor versions.
Directory Layout (monorepo example)
```
/graph
/schema
user.graphql
post.graphql
/resolvers
user.resolver.ts
post.resolver.ts
/loaders
user.loader.ts
post.loader.ts
/plugins
complexity.ts
auth.ts
/tests
user.test.ts
index.ts (ApolloServer bootstrap)
```
Common Pitfalls & How to Avoid
- N+1 queries → Always benchmark; add DataLoader early.
- Over-fetching → Educate clients; use persisted queries + lint rule disallowing `*` selections.
- Missing cache invalidation → Publish mutation events; subscribe loaders to clear.
- Long resolver chains → Refactor into service layer; flatten nested calls.
- Giant monolithic query → Encourage fine-grained UI components with scoped data requirements.
Versioning & Lifecycle
- Version schema via `graphVariant` in Apollo GraphOS (`current`, `next`).
- Release procedure: validate breaking changes with `rover graph check`, then deploy.