GraphQL schema design
Have Agents follow consistent naming and evolution rules for types, federation subgraphs, and list fields; encode Connection and cursor strategy in the SKILL to avoid N+1, duplicate entities across services, and breaking changes in production.
Drive Object and Input boundaries from the domain: mutation inputs should be explicit, outputs stable; for public APIs prefer Connection or team-standard cursor pagination, and document default first caps with resolver enforcement in the SKILL.
With multiple subgraphs: one subgraph “owns” a field set per entity; others extend via federation directives; avoid conflicting scalar fields or non-key resolution paths for the same entity on two subgraphs.
- Naming: types PascalCase, fields camelCase, enums SCREAMING_SNAKE_CASE.
- Nullability and defaults: separate “unset” from explicit null to avoid client ambiguity.
- Alongside REST: fix in the SKILL whether BFF aggregates or resolvers own which reads.
Schema design flow (skill-flow-block)
[ Domain model / use cases ]
│
▼
[ Split subgraphs or monolith: Query entry, Mutation, Subscription ]
│
▼
[ Types: Object / Input / Enum; lists → Connection or explicit limit+offset+cap ]
│
┌──────┴──────┐
▼ ▼
[ Federation: @key / @external / resolver ownership ] [ Resolvers: DataLoader, stable cursors, max first ]
│ │
└──────┬──────┘
▼
[ @deprecated + reason; schema registry / contract tests block breaking in CI ]
│
▼
[ Depth / complexity / introspection policy; staging matches prod ]
When Agents emit SDL, self-check in this order: are entry fields too wide, do lists lack caps, does exactly one “primary” subgraph own key fields for federated entities, and is first ≤ max enforced in resolvers.
DataLoader: solving the N+1 problem
// DataLoader complete implementation: batch loading solves the N+1 problem
import DataLoader from 'dataloader';
// Batch loading function: merges N individual queries into 1 IN query
const userLoader = new DataLoader<string, User>(async (userIds) => {
// userIds is the set of all ids requested within the same tick (auto-deduplicated)
const users = await db.users.findMany({
where: { id: { in: [...userIds] } }
});
// Must return results in the same order as userIds; missing entries get null
return userIds.map(id => users.find(u => u.id === id) ?? null);
}, {
cache: true, // in-request cache (create a new loader instance per request)
maxBatchSize: 100, // max 100 per batch to avoid oversized IN queries
});
// Using in a resolver: each .load() call is automatically batched by DataLoader
const resolvers = {
Post: {
// Fetching 100 posts will NOT trigger 100 separate user queries
author: (post, _, { loaders }) => loaders.user.load(post.authorId),
},
};
// Create a new DataLoader instance per HTTP request (avoid cross-request cache pollution)
function createContext(req) {
return {
loaders: {
user: new DataLoader(async (ids) => batchLoadUsers(ids)),
post: new DataLoader(async (ids) => batchLoadPosts(ids)),
}
};
}
-
Entities and @key: types resolved across services use
@keyto declare primary fields; extending subgraphs use@externalto reference foreign fields and add only incremental fields or resolver logic locally. - Coordinated change: changing keys or moving field ownership is a breaking change; the SKILL should require updating the subgraph inventory, running compatibility checks, and defining a rollback strategy.
Cursor pagination schema and query protection
# Cursor pagination Connection/Edge/PageInfo standard pattern (Relay spec)
"""Pagination info (defined once globally; reused by all Connections)"""
type PageInfo {
hasNextPage: Boolean!
hasPreviousPage: Boolean!
startCursor: String
endCursor: String # opaque cursor — clients should not parse its contents
}
type UserEdge {
cursor: String!
node: User!
}
type UserConnection {
edges: [UserEdge!]!
pageInfo: PageInfo!
totalCount: Int # expensive — enable on demand; avoid triggering COUNT(*) by default
}
extend type Query {
"""List users (first max 100; over-limit returns BAD_USER_INPUT)"""
users(
first: Int! # required; resolver enforces the cap
after: String
filter: UserFilter
): UserConnection!
}
// Query depth limiting (depth-limit middleware) + complexity analysis
import depthLimit from 'graphql-depth-limit';
import { createComplexityRule } from 'graphql-query-complexity';
const server = new ApolloServer({
validationRules: [
depthLimit(5), // max nesting depth 5; prevents deep-nesting attacks
createComplexityRule({
maximumComplexity: 1000, // max complexity score
variables: {},
onComplete: (complexity) => console.log(`Query complexity: ${complexity}`),
estimators: [
fieldExtensionsEstimator(),
simpleEstimator({ defaultComplexity: 1 }),
],
}),
],
plugins: [
{
requestDidStart: () => ({
didResolveOperation({ request, document }) {
// Disable introspection in production (prevent schema leakage)
if (process.env.NODE_ENV === 'production' &&
request.operationName === 'IntrospectionQuery') {
throw new ForbiddenError('Introspection disabled in production');
}
}
})
}
],
});
// Resolver-level authorization: check permissions inside the resolver, never rely on caller discipline
const resolvers = {
Query: {
adminUsers: (_, __, { user }) => {
if (!user || user.role !== 'admin')
throw new ForbiddenError('Admin access required'); // 403
return db.users.findAll();
},
},
};
Deprecation, registry, and cost / security
Use @deprecated with a replacement for fields slated for removal; block major changes via schema registry or contract tests in CI. For multiple subgraphs, composition failures or version drift must fail the pipeline explicitly.
Security and cost: require query depth, complexity, and introspection policy in the skill text so open endpoints cannot be abused to overload resolvers.
Connection SDL lab (gql-page)
Enter node type and list field names to generate a pasteable Relay-style Connection skeleton for a subgraph (comments remind you to enforce first caps in resolvers). Generation runs only in the browser.
Resolvers must validate first: if callers pass more than 100, throw a domain error or clamp to the cap (pick one and document in the SKILL).
---
name: graphql-schema-design
description: Produce evolvable GraphQL SDL with security guards and N+1 solutions
---
# Schema design
1. Type naming: Object/Input/Enum PascalCase, fields camelCase, enums ALL_CAPS
2. List fields: use Connection pattern (edges/node/pageInfo); first is required with an enforced cap
3. Opaque cursors: clients must not parse cursor contents; server is free to change encoding strategy
4. totalCount is expensive: make it an opt-in field or feature flag; never trigger COUNT by default
# N+1 and performance
5. Each resolver uses DataLoader to batch-load related data
6. Create a new DataLoader instance per HTTP request to avoid cross-request cache pollution
7. Batch functions must return results in the same order as input ids; missing entries use null
# Query protection
8. Depth limiting: use graphql-depth-limit to set max nesting depth (recommended 5–10)
9. Complexity analysis: set maximumComplexity and log the score for every query
10. Disable introspection in production (prevents schema structure leakage)
11. Resolver-level auth: check permissions inside each sensitive resolver — never rely on caller discipline
# Evolution and deprecation
12. Deprecate fields: @deprecated(reason: "Use newField instead")
13. Schema Registry + CI blocks breaking changes
14. Federation: one subgraph owns the entity via @key; others reference it with @external
15. Before changing a @key or moving field ownership, audit all dependent subgraphs and define a rollback plan