Detection Signals
graphqlor@graphql-tools/*inpackage.json.graphql/.gqlschema files in the repositoryapollo-server,graphql-yoga,strawberry-graphql,ariadne,gqlgen,async-graphqldependencyGRAPHQL_ENDPOINTenv var or/graphqlroute in routing configApolloClient,urql, orgraphql-requeston 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
# Good — explicit, typed arguments
type Query {
user(id: ID!): User
users(filter: UserFilterInput, page: Int, perPage: Int): UserConnection!
order(id: ID!): Order
}
# Bad — catch-all input loses discoverability
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!
}
# Payload: return the mutated entity + optional user-facing errors
type CreateUserPayload {
user: User
errors: [UserError!]!
}
type UserError {
field: String # which field caused the error (null = global error)
message: String!
code: String! # machine-readable code for client handling
}
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 nullableerror— 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):
// Without DataLoader — fires 1 query per user (N+1)
const resolver = {
Order: {
user: (order) => db.users.findById(order.userId), // ❌
},
}
// With DataLoader — batches into 1 query for all orders in the request
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.
# Relay-style connection pattern
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 totalCountis 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.
// Include machine-readable codes in extensions for client handling
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
/graphqlendpoint, not just on individual resolvers
What to Do Before Declaring Done
- No resolver fetches related data without going through a DataLoader
- All list fields are paginated
- Mutation payloads return the mutated entity
- Business errors use the
errors: [UserError!]!pattern - Subscriptions filter server-side
- Introspection disabled in production config
- Depth/complexity limits configured
Source: Dev-Toolbelt/dev-team-agents — distributed by TomeVault.