const typeDefs = gql`
type User {
id: ID!
displayName: String!
avatarUrl: String!
currentBalance: Float!
# Heavy metrics are isolated in a dedicated type
metrics: UserMetrics
}
type UserMetrics {
loyaltyTier: String!
tierProgress: Float!
lifetimeRevenue: Float!
referralCount: Int!
completedTasks: Int!
achievementBadges: [String!]!
}
type Query {
viewer: User!
}
`;
**Rationale:** Isolating `UserMetrics` prevents the GraphQL engine from automatically resolving heavy fields when only `displayName` or `avatarUrl` is requested. The schema explicitly communicates computational boundaries to both frontend developers and the execution engine.
### Step 2: Resolver Architecture with Lazy Computation
Resolvers should never perform database queries directly. Instead, they delegate to a data access layer that handles batching, caching, and connection pooling. Heavy metrics are implemented as lazy resolvers that only execute when the client includes them in the query.
```typescript
// resolvers.ts
import { Resolvers } from './generated/graphql-types';
import { UserRepository } from './repositories/user.repository';
import { MetricsRepository } from './repositories/metrics.repository';
export const resolvers: Resolvers = {
Query: {
viewer: (_, __, context) => {
// Returns a lightweight user object.
// Heavy fields remain unresolved until explicitly requested.
return context.userRepo.findById(context.userId);
},
},
User: {
metrics: async (parent, _, context) => {
// Lazy resolution: only triggers when client queries `metrics`
const userId = parent.id;
return context.metricsRepo.fetchAggregatedStats(userId);
},
},
};
Rationale: GraphQL's execution engine traverses the query tree depth-first. By returning a reference object from the root viewer query and deferring heavy computation to the User.metrics resolver, we ensure database aggregates run only when the client explicitly requests them. This pattern reduces baseline CPU usage by eliminating unnecessary SUM, COUNT, and JOIN operations.
Step 3: DataLoader Integration for N+1 Prevention
When multiple components request related entities (e.g., a feed displaying 20 posts, each requiring author details), naive resolvers trigger 20 separate database calls. DataLoader batches these requests into a single query and deduplicates results.
// context.ts
import { DataLoader } from 'dataloader';
import { UserRepository } from './repositories/user.repository';
export function createContext(userId: string, db: any) {
const userRepo = new UserRepository(db);
return {
userId,
userRepo,
// Batches user lookups into a single IN query
userLoader: new DataLoader(async (ids: readonly string[]) => {
const users = await userRepo.findByIds(ids as string[]);
// DataLoader requires results in the exact order of input IDs
return ids.map(id => users.find(u => u.id === id) || null);
}),
// Metrics loader batches heavy aggregations
metricsLoader: new DataLoader(async (ids: readonly string[]) => {
const stats = await metricsRepo.fetchBatchStats(ids as string[]);
return ids.map(id => stats.find(s => s.userId === id) || null);
}),
};
}
Rationale: DataLoader operates per-request lifecycle. It collects all resolution calls within a single event loop tick, executes one batched query, and distributes results back to individual resolvers. This eliminates the N+1 query problem without requiring complex SQL subqueries or manual frontend data joining.
Step 4: Client-Side Query Construction
Frontend applications construct queries that match exact UI requirements. Different screens request different field subsets, and the execution engine resolves only what is asked.
// queries.ts
import { gql } from '@apollo/client';
// Lightweight: used in navigation headers, global state
export const APP_HEADER_QUERY = gql`
query AppHeader {
viewer {
displayName
avatarUrl
currentBalance
}
}
`;
// Heavy: used only when user navigates to analytics dashboard
export const ANALYTICS_DASHBOARD_QUERY = gql`
query AnalyticsDashboard {
viewer {
displayName
metrics {
loyaltyTier
tierProgress
lifetimeRevenue
referralCount
completedTasks
achievementBadges
}
}
}
`;
Rationale: Client-side query construction shifts data ownership to the presentation layer. Frontend teams can iterate on UI requirements without backend contract changes. The server remains a pure computation engine, responding only to explicit field requests.
Pitfall Guide
Implementing a client-driven API introduces new failure modes. The following pitfalls represent common production incidents and their proven mitigations.
1. The N+1 Query Trap
Explanation: Developers write resolvers that execute database queries directly. When a list field returns 50 items, each item's nested resolver triggers a separate query, resulting in 51 database calls.
Fix: Always wrap data access in DataLoader. Configure batch functions to accept arrays of IDs and return results in the exact input order. Never bypass the loader in nested resolvers.
2. Unbounded Query Complexity
Explanation: Clients can construct deeply nested queries or request massive list sizes, causing exponential execution time and database strain.
Fix: Implement query complexity analysis middleware. Assign cost weights to fields (e.g., 1 for scalars, 10 for lists, 50 for aggregations). Reject queries exceeding a configurable threshold before execution begins.
3. Resolver Side Effects
Explanation: Developers accidentally place state-mutating logic (e.g., incrementing counters, sending notifications) inside query resolvers. Queries should be idempotent and safe to retry.
Fix: Strictly separate read and write operations. Use Query type for data retrieval and Mutation type for state changes. Enforce this boundary through code review checklists and linting rules.
4. Caching Invalidation Blind Spots
Explanation: Client-side normalized caches (Apollo, urql) store objects by ID. When a mutation updates a user, cached fragments across multiple components become stale without explicit eviction.
Fix: Configure cache update policies in mutations. Use readQuery/writeQuery or cache.modify to update normalized records. Implement optimistic UI updates with rollback handlers for network failures.
5. Exposing Introspection in Production
Explanation: GraphQL's introspection feature allows clients to query the entire schema structure. Leaving it enabled in production exposes internal type names, field descriptions, and potential security boundaries.
Fix: Disable introspection in production environments. Use environment variables to conditionally enable it only in development. Implement schema stitching or federation if internal services require cross-domain discovery.
6. DataLoader Context Lifecycle Mismanagement
Explanation: Creating DataLoader instances globally or sharing them across requests causes data leakage between users and stale batch caches.
Fix: Instantiate DataLoader inside the request context factory. Ensure each HTTP request receives a fresh context with new loader instances. Clear loaders automatically when the request completes.
7. Overcomplicating the Schema
Explanation: Teams attempt to model every database relationship directly in GraphQL, creating circular references and resolution deadlocks.
Fix: Design schemas around UI requirements, not database tables. Use interface types for shared fields, union types for polymorphic results, and keep resolvers thin. Delegate complex joins to the data access layer.
Production Bundle
Action Checklist
Decision Matrix
| Scenario | Recommended Approach | Why | Cost Impact |
|---|
| High-traffic dashboard with static metrics | Persisted queries + CDN caching | Reduces server parsing overhead and enables edge caching | Lowers compute costs by ~40% |
| Real-time social feed | GraphQL Subscriptions + DataLoader | Enables push-based updates while batching author lookups | Increases WebSocket connections but reduces DB load |
| Mobile app with limited bandwidth | Selective field queries + response compression | Minimizes payload size and eliminates unused data transmission | Reduces bandwidth costs by 60-80% |
| Enterprise B2B platform | Schema federation + gateway routing | Allows domain teams to own subgraphs while presenting unified API | Increases architectural complexity but improves team autonomy |
| Legacy REST migration | GraphQL gateway with REST data sources | Enables gradual migration without rewriting backend services | Moderate initial setup cost, zero downtime migration |
Configuration Template
// server.ts
import { createYoga } from 'graphql-yoga';
import { useSchema } from '@graphql-yoga/plugin-schema';
import { useDepthLimit } from '@graphql-yoga/plugin-depth-limit';
import { usePersistedQueries } from '@graphql-yoga/plugin-persisted-queries';
import { typeDefs } from './schema';
import { resolvers } from './resolvers';
import { createContext } from './context';
import { db } from './database';
export const yoga = createYoga({
schema: { typeDefs, resolvers },
context: ({ request }) => {
const userId = request.headers.get('x-user-id') || 'anonymous';
return createContext(userId, db);
},
plugins: [
// Prevents deeply nested queries from exhausting server resources
useDepthLimit({ maxDepth: 7 }),
// Enables caching of compiled queries for high-traffic endpoints
usePersistedQueries({
ttl: 3600,
maxSize: 1000,
}),
// Disables introspection in production environments
process.env.NODE_ENV === 'production'
? {
onParams: ({ params }) => {
if (params.query?.includes('__schema') || params.query?.includes('__type')) {
throw new Error('Introspection disabled in production');
}
}
}
: undefined,
].filter(Boolean),
graphqlEndpoint: '/api/v1/graphql',
landingPage: false,
});
Quick Start Guide
- Initialize the project: Run
npm init -y && npm install graphql-yoga dataloader @apollo/client graphql to set up the server and client dependencies.
- Define the schema: Create a
schema.ts file with type definitions that separate lightweight profile fields from heavy computational metrics.
- Implement resolvers: Build thin resolver functions that delegate to a data access layer. Wrap all entity lookups in DataLoader instances to prevent N+1 queries.
- Configure the server: Set up Yoga or Apollo Server with depth limiting, persisted queries, and production-safe introspection settings. Attach the request context factory to inject DataLoader instances per request.
- Test with client queries: Use Apollo Client or urql to construct selective queries. Verify that heavy metrics only trigger database aggregations when explicitly requested, and monitor resolver execution times to confirm lazy computation behavior.