| name | graphql |
| description | GraphQL — schema, resolvers, N+1/DataLoader, pagination. Use when this capability is needed. |
| metadata | {"author":"Dev-Toolbelt"} |
Detection Signals
graphql or @graphql-tools/* in package.json
.graphql / .gql schema files in the repository
apollo-server, graphql-yoga, strawberry-graphql, ariadne, gqlgen, async-graphql dependency
GRAPHQL_ENDPOINT env var or /graphql route in routing config
ApolloClient, urql, or graphql-request on the frontend
Schema-First vs Code-First
Detect which approach the project uses before writing any schema or resolver:
| Signal | Approach |
|---|
.graphql / .gql files are the source of truth | Schema-first |
| Schema generated from code annotations / decorators | Code-first |
- Schema-first: SDL (Schema Definition Language) files are committed; resolvers implement the contract.
- Code-first: resolvers are annotated and the schema is generated at build time (e.g. TypeGraphQL, Strawberry, gqlgen).
Do not mix approaches. Follow whichever the project already uses.
Naming Conventions
| Element | Convention | Example |
|---|
| Types | PascalCase | User, OrderItem |
| Fields | camelCase | createdAt, totalAmount |
| Queries | camelCase verb-noun | user(id), listOrders |
| Mutations | camelCase action-noun | createUser, updateOrder, deleteProduct |
| Subscriptions | camelCase event | orderStatusChanged, messageReceived |
| Enums | SCREAMING_SNAKE_CASE values | ORDER_STATUS_PENDING |
| Input types | [Action][Type]Input | CreateUserInput, UpdateOrderInput |
Never expose internal database column names directly — map them to clean field names in the schema.
Query Design
type Query {
user(id: ID!): User
users(filter: UserFilterInput, page: Int, perPage: Int): UserConnection!
order(id: ID!): Order
}
type Query {
users(input: JSON): [User]
}
- Mark fields non-null (
!) only when the server guarantees a value — never lie about nullability
- Use
! on list fields ([User!]!) when neither the list nor its elements can be null
- Input arguments for filtering should use dedicated Input types, not scalars
Mutation Conventions
Wrap mutation arguments in a single input object for extensibility and forward-compatibility:
type Mutation {
createUser(input: CreateUserInput!): CreateUserPayload!
updateOrder(id: ID!, input: UpdateOrderInput!): UpdateOrderPayload!
}
input CreateUserInput {
email: String!
name: String!
role: UserRole!
}
type CreateUserPayload {
user: User
errors: [UserError!]!
}
type UserError {
field: String
message: String!
code String
Rules:
- Return the mutated object in the payload so clients can update their cache without a refetch
- Use
errors: [UserError!]! (always-present empty array) instead of nullable error — this is the Errors-as-Data pattern
- Reserve top-level GraphQL errors for unexpected failures (auth, server crash), not for business-rule violations
N+1 Prevention — DataLoader
Every resolver that loads related data by ID must use a DataLoader (batching + caching per request):
const resolver = {
Order: {
user: (order) => db.users.findById(order.userId),
},
}
const userLoader = new DataLoader(async (ids: string[]) => {
const users = await db.users.findByIds(ids);
return ids.map(id => users.find(u => u.id === id) ?? null);
});
const resolver = {
Order: {
user: (order, _, ctx) => ctx.loaders.user.load(order.userId),
},
}
Rules:
- Create DataLoaders per request (in context), never as singletons — singletons leak data between requests
- One DataLoader per entity type (
userLoader, productLoader, etc.)
- Before writing a resolver that fetches related data, check whether a loader already exists in the context
Pagination
Use Relay cursor-based pagination for collections that can grow large. Use offset (page / perPage) only for small, stable lists.
type UserConnection {
edges: [UserEdge!]!
pageInfo: PageInfo!
totalCount: Int!
}
type UserEdge {
node: User!
cursor: String!
}
type PageInfo {
hasNextPage: Boolean!
hasPreviousPage: Boolean!
startCursor: String
endCursor: String
}
type Query {
users(first: Int, after: String, last: Int, before: String): UserConnection!
}
Rules:
- Never return unbounded lists (
[User!]! without pagination args) — always paginate
totalCount is expensive on large tables; make it optional or computed lazily
- Cursors must be opaque to clients (base64-encode the underlying offset or ID)
Subscriptions
type Subscription {
orderStatusChanged(orderId: ID!): OrderStatusEvent!
}
type OrderStatusEvent {
orderId: ID!
status: OrderStatus!
updatedAt: String!
}
Rules:
- Filter events server-side — never send all events and filter client-side
- Subscriptions must enforce the same auth rules as queries/mutations
- Keep subscription payloads small — include IDs and changed fields; let the client refetch full data if needed
- Always handle connection cleanup (unsubscribe on disconnect) to prevent memory leaks server-side
Error Handling
| Error type | Where it goes | Example |
|---|
| Business rule violation | errors field in payload | "Email already taken" |
| Auth failure | Top-level GraphQL error with extensions.code: UNAUTHENTICATED | Token expired |
| Forbidden | Top-level GraphQL error with extensions.code: FORBIDDEN | Missing permission |
| Input validation | errors field in payload | "Email format invalid" |
| Unexpected server error | Top-level GraphQL error with extensions.code: INTERNAL_SERVER_ERROR | DB connection lost |
Never expose stack traces or internal error details to clients in production.
throw new GraphQLError("Not authenticated", {
extensions: { code: "UNAUTHENTICATED" },
});
Security
- Depth limiting: reject queries deeper than a configured threshold (typically 5–7 levels) to prevent deeply nested query attacks
- Complexity limiting: assign cost weights to fields; reject queries exceeding a total budget
- Introspection: disable in production unless the API is public and introspection is intentional
- Field authorization: check permissions per resolver, not just at the operation level — a query can reach sensitive fields through nested types
- Rate limiting: apply per-IP or per-user limits on the
/graphql endpoint, not just on individual resolvers
What to Do Before Declaring Done
Source: Dev-Toolbelt/dev-team-agents — distributed by TomeVault.