A GraphQL server executes a query by calling one small function per field in the selection set, not one big function per request. Understanding resolvers at that granularity — what gets called, in what order, and with what arguments — explains most of the performance and structure problems people run into, and most of the fixes.
The resolver function signature
Every resolver receives the same four arguments, regardless of GraphQL server implementation: (parent, args, context, info). parent is the result of the resolver for the enclosing field (or the root value for top-level queries). args holds the arguments passed to this field in the query. context is a per-request object shared across every resolver invocation in that request — commonly used for the authenticated user, a database connection, and DataLoader instances. info carries metadata about the query itself: the field name, the AST, the schema. Most resolvers only ever touch the first three.
Field resolvers and default resolution
Every field in a selection set is resolved independently. If you don't define a resolver for a field, the GraphQL engine falls back to a default resolver that just reads a same-named property off the parent object — that's why a plain object returned from a query resolver "just works" for its scalar fields without anyone writing name: (parent) => parent.name by hand. You only write a custom resolver when the field needs computation, a rename, or a separate data fetch:
const resolvers = {
Query: {
user(parent, args, context, info) {
return context.services.users.findById(args.id);
},
},
User: {
// default resolver would work for name/email;
// this one is custom because posts isn't a property on the row
posts(parent, args, context) {
return context.loaders.postsByUserId.load(parent.id);
},
},
};
Resolver chains for nested types
A query like { user(id: "1") { name posts { title } } } doesn't resolve in one pass. The engine first calls Query.user, which returns a user object. That object becomes the parent for every field under it: name resolves (default resolver, reads the property), and posts resolves via the custom resolver above, returning an array of post objects. Each of those post objects then becomes the parent for its own title field resolver. This chaining is what makes GraphQL's nested shape work without every type needing to know how to fetch its own relations upfront — each resolver only needs to know how to get from its parent to its own data.
Using context for per-request state
Because context is created fresh for each incoming request and passed to every resolver in that request's execution, it's the right place for anything that's per-request rather than global: the authenticated user (so a resolver can check context.user.id before returning private fields), a request-scoped database transaction, and — critically — DataLoader instances, which must not be shared across requests or you'll leak one user's batched cache into another's.
Avoiding N+1 inside resolvers
If User.posts queries the database directly inside the resolver, a list of 50 users produces 50 separate post queries — the classic N+1 problem, since each user's resolver fires independently and has no visibility into its siblings. DataLoader solves this by batching: calls to loader.load(id) made within the same tick are collected and issued as a single batched query, then the individual results are handed back to each waiting resolver. The resolver itself doesn't change its shape — it still just calls load() — the batching happens underneath, keyed off context.loaders created once per request.
A DataLoader caches by key for the lifetime of the instance. Sharing one instance across requests means user A's query results can leak into user B's response via the cache. Always construct DataLoaders inside the per-request context factory, never at module scope.
Separating resolvers from business logic
A resolver's job is to translate between the GraphQL execution engine and your application's actual logic — not to contain that logic itself. A resolver with raw SQL, validation rules, and authorization checks embedded in its body is hard to reuse (a REST endpoint needing the same logic can't call it) and hard to test in isolation from the GraphQL layer. The pattern that scales is thin resolvers that delegate to a service layer:
| Resolver does | Service layer does |
|---|---|
| Reads args and context | Validates input, enforces business rules |
| Calls one service method | Talks to the database or other services |
| Shapes the return value if needed | Owns the actual domain logic |
Keeping that boundary means the same "create order" logic backs both a GraphQL mutation resolver and, if you ever need it, a REST controller or a background job — the resolver is just one of possibly several callers, not the place the logic lives.
Independent software engineer in Nairobi specialising in Acumatica customisations, Laravel backends, and tax fiscalisation integrations across East and Southern Africa.