HomeCurriculumGraphQLResolver Architecture & Context Passing
Resolver Architecture & Context Passing

Resolver Architecture & Context Passing

Analyze the 4-argument resolver signature (parent, args, context, info), field-level authorization, and null bubble-up mechanics.

DT
Authored by DevThesis Systems Editorial Team
Reviewed for Technical Accuracy & Code Determinism

While the Schema Definition Language (SDL) defines the static structural contract of a GraphQL API, the Resolver Architecture represents the dynamic execution engine that transforms abstract queries into concrete application data. A common misconception among engineers new to GraphQL is that the server possesses an automated, magical mechanism that maps queries directly to a database. In reality, a GraphQL server is fundamentally a structured function execution tree.

Every single field on every type in a GraphQL schema is backed by a resolver function. When an incoming query is parsed and validated, the GraphQL runtime traverses the Abstract Syntax Tree (AST), invoking resolvers in a nested, hierarchical cascade. Mastering resolver signatures, context injection, default resolver optimizations, and field-level authorization is paramount to building performant, secure, and maintainable enterprise GraphQL services.


The Resolver Tree Execution Model#

A GraphQL query execution is modeled as a Directed Acyclic Graph (DAG) or tree traversal. The runtime engine starts at the root operation type (Query, Mutation, or Subscription) and resolves fields level by level, passing the result of each parent field down to its child resolvers.

text
+-----------------------------------------------------------------------------------+
| Hierarchical Resolver Execution Tree                                              |
|                                                                                   |
|  Incoming Query:                                                                  |
|  query {                                                                          |
|    user(id: "42") {         <-- Level 0 (Root Operation)                          |
|      name                   <-- Level 1 (Scalar Leaf)                             |
|      orders(limit: 2) {     <-- Level 1 (Composite Child of User)                 |
|        id                   <-- Level 2 (Scalar Leaf)                             |
|        items {              <-- Level 2 (Composite Child of Order)                |
|          productName        <-- Level 3 (Scalar Leaf)                             |
|        }                                                                          |
|      }                                                                            |
|    }                                                                              |
|  }                                                                                |
|                                                                                   |
|  Execution Cascade:                                                               |
|                                                                                   |
|                 +--------------------------------+                                |
|                 | Root: Query.user(id: "42")     |                                |
|                 | Returns: User object { id: 42 }|                                |
|                 +---------------+----------------+                                |
|                                 |                                                 |
|                 +---------------+----------------+                                |
|                 | parent = User { id: 42 }       |                                |
|                 v                                v                                |
|    +--------------------------+    +--------------------------------+             |
|    | User.name                |    | User.orders(limit: 2)          |             |
|    | Returns: "Alice Johnson" |    | Returns: [Order#1, Order#2]    |             |
|    +--------------------------+    +----------------+---------------+             |
|                                                     |                             |
|                                     +---------------+---------------+             |
|                                     | parent = Order#1 / Order#2    |             |
|                                     v                               v             |
|                       +--------------------------+    +--------------------------+|
|                       | Order.id                 |    | Order.items              ||
|                       | Returns: "ord_101"       |    | Returns: [ItemA, ItemB]  ||
|                       +--------------------------+    +-------------+------------+|
|                                                                     |             |
|                                                                     v             |
|                                                       +--------------------------+|
|                                                       | Item.productName         ||
|                                                       | Returns: "Mechanical Key"||
|                                                       +--------------------------+|
+-----------------------------------------------------------------------------------+

The 4-Argument Resolver Function Signature#

In standard GraphQL implementations (including graphql-js, Apollo Server, GraphQL Yoga, and Mercurius), every resolver function adheres strictly to a 4-argument signature:

typescript
function resolveField(
  parent: TParent,
  args: TArgs,
  context: TContext,
  info: GraphQLResolveInfo
): Promise<TResult> | TResult;

1. `parent` (also referred to as `root` or `source`)#

The resolved return value of the immediate parent field in the execution hierarchy.

  • For top-level fields on Query or Mutation, parent is typically undefined or a root value provided during server initialization.
  • For child fields, parent contains the object returned by the parent resolver. In the diagram above, inside the User.orders resolver, parent is the User object ({ id: '42', name: 'Alice' }) returned by Query.user.

2. `args`#

A plain JavaScript object containing the arguments explicitly passed to this field in the GraphQL query document:

orders(limit: 2, status: PENDING) -> args = { limit: 2, status: 'PENDING' }.

The GraphQL validation layer guarantees that all arguments conform to the types and nullability constraints defined in the SDL schema before the resolver is invoked.

3. `context`#

A per-request, globally shared object constructed once at the beginning of an incoming HTTP transaction. It is passed unmodified to every single resolver across the entire execution tree.

  • What belongs in Context:

* Authenticated user identity and permissions (currentUser, roles, tenantId).

* Database connection pools or ORM client instances (Prisma, Drizzle, Knex).

* DataLoaders for batching and caching database lookups.

* Distributed tracing spans (OpenTelemetry context) and correlation IDs (requestId).

* Low-level HTTP primitives (request headers, cookies, client IP).

4. `info`#

An instance of GraphQLResolveInfo containing metadata regarding the execution state:

  • The field's AST node in the parsed query (fieldNodes).
  • The return type and parent type definitions from the schema.
  • The exact path in the response tree (e.g., ['user', 'orders', 0, 'items']).
  • Variable values passed with the operation.

Advanced resolvers inspect info to analyze which downstream sub-fields were requested, enabling dynamic SQL SELECT projections.


Default (Trivial) Resolvers#

A common question is: If every field requires a resolver, do developers have to manually write thousands of boilerplate functions for simple properties like `id`, `name`, and `email`?

The answer is No. If a field does not have an explicit resolver assigned in the resolver map, the GraphQL execution engine applies the Default Field Resolver:

javascript
// Conceptual implementation of graphql-js default resolver
function defaultFieldResolver(source, args, context, info) {
  if (typeof source === 'object' && source !== null) {
    const property = source[info.fieldName];
    if (typeof property === 'function') {
      return property.call(source, args, context, info);
    }
    return property;
  }
}

If the parent object has a property matching the field name (source.name), the engine returns it automatically. Developers write custom resolvers only when:

  1. Transforming or computing data (e.g., computing fullName from firstName and lastName).
  2. Fetching asynchronous relations across database tables or microservices (e.g., fetching User.orders).
  3. Enforcing field-level authorization (e.g., masking User.ssn).

Context Passing Lifecycle & Per-Request Isolation#

Constructing the context object correctly is critical for thread-safety, memory isolation, and security.

text
+-----------------------------------------------------------------------------------+
| The Context Construction Lifecycle                                                |
|                                                                                   |
|  [ Incoming HTTP Request: Authorization: Bearer <token>, X-Request-ID: <uuid> ]   |
|                         |                                                         |
|                         v                                                         |
|  +-----------------------------------------------------------------------------+  |
|  | Web Framework HTTP Middleware (Express / Fastify / Koa)                      |  |
|  | - Extracts Bearer token from headers                                        |  |
|  | - Parses client IP and correlation ID                                        |  |
|  +-------------------------------------+---------------------------------------+  |
|                                        | Calls context() factory                  |
|                                        v                                          |
|  +-----------------------------------------------------------------------------+  |
|  | GraphQL Context Factory Function                                            |  |
|  | 1. Verifies JWT / session token; populates `currentUser`                    |  |
|  | 2. Instantiates fresh, isolated DataLoaders (e.g., new UserLoader(db))       |  |
|  | 3. Injects database transaction handle or read-replica pool                 |  |
|  | 4. Binds OpenTelemetry tracer span to `reqId`                               |  |
|  +-------------------------------------+---------------------------------------+  |
|                                        | Produces immutable request context       |
|                                        v                                          |
|  +-----------------------------------------------------------------------------+  |
|  | GraphQL Schema Execution Engine (execute())                                 |  |
|  | - Context object passed as 3rd parameter to every resolver across the tree  |  |
|  | - Resolvers read context.currentUser without cross-request state leakage    |  |
|  +-----------------------------------------------------------------------------+  |
+-----------------------------------------------------------------------------------+

Critical Architectural Principle: Strict Request Isolation#

The context object MUST be instantiated freshly for each individual HTTP request.

If DataLoaders or authentication states are cached globally or attached to server singletons:

  • Severe Security Breach: User A's private data or permissions will be served to User B because DataLoaders cache results in memory.
  • Memory Leaks: Global caches retain database rows indefinitely, exhausting server heap memory.
  • Concurrency Race Conditions: Concurrent requests from different tenants overwrite shared credentials.

GraphQL Execution Engine Algorithm#

Understanding the formal execution algorithm specified in Section 6 of the Official GraphQL Specification clarifies how promises, parallelism, and serial guarantees interact.

1. Query Field Execution: Concurrent/Parallel#

For queries, fields at the same level of the AST selection set are evaluated concurrently:

typescript
// Conceptual parallel execution of Query selection set
async function executeQueryFields(fields, parent, context, info) {
  const resultObject = {};
  
  // All sibling fields dispatch their resolvers simultaneously
  const fieldPromises = fields.map(async (field) => {
    const resolver = getResolver(field);
    const resolvedValue = await resolver(parent, field.args, context, info);
    
    // If composite type, recursively execute sub-selection set
    if (field.hasSubFields) {
      resultObject[field.aliasOrName] = await executeSubFields(field.subFields, resolvedValue);
    } else {
      resultObject[field.aliasOrName] = resolvedValue;
    }
  });

  await Promise.all(fieldPromises);
  return resultObject;
}

If a query requests user { profile { ... } } and systemMetrics { ... }, both database lookups initiate concurrently via Promise.all().

2. Mutation Field Execution: Strictly Serial#

In stark contrast to queries, the root fields of a mutation operation are executed serially:

typescript
// Conceptual serial execution of Mutation root fields
async function executeMutationFields(fields, context, info) {
  const resultObject = {};

  // For-loop enforces strict sequential execution
  for (const field of fields) {
    const resolver = getResolver(field);
    // Must await completion of current mutation before proceeding to next
    const resolvedValue = await resolver(null, field.args, context, info);
    resultObject[field.aliasOrName] = resolvedValue;
  }

  return resultObject;
}

This guarantees that if a client submits:

graphql
mutation OrderPipeline {
  chargeCard(amount: 50) { transactionId }
  shipItem(itemId: "902") { trackingNumber }
}

The credit card is charged and committed to the ledger before the shipping system creates the dispatch record.


Field-Level Authorization & Middleware Architecture#

In traditional REST architectures, authorization is typically enforced at the endpoint level via gateway route guards (e.g., router.get('/admin/users', requireAdmin, ...)). In GraphQL, because all requests route through a single /graphql endpoint and clients query arbitrary combinations of fields, authorization must be enforced at the field level.

text
+-----------------------------------------------------------------------------------+
| Field-Level Authorization Pipeline                                                |
|                                                                                   |
|  [ Client Query: { user(id: "42") { id name email ssn } } ]                       |
|                         |                                                         |
|                         v                                                         |
|  +-----------------------------------------------------------------------------+  |
|  | Field: User.id / User.name                                                  |  |
|  | - Policy: Public / Authenticated                                            |  |
|  | - Check: Is user logged in? YES -> Returns "Alice Johnson"                  |  |
|  +-----------------------------------------------------------------------------+  |
|                         |                                                         |
|                         v                                                         |
|  +-----------------------------------------------------------------------------+  |
|  | Field: User.email                                                           |  |
|  | - Policy: Self-Or-Admin                                                     |  |
|  | - Check: currentUser.id === parent.id OR currentUser.role === 'ADMIN'?     |  |
|  | - Evaluation: True -> Returns "alice@devthesis.com"                         |  |
|  +-----------------------------------------------------------------------------+  |
|                         |                                                         |
|                         v                                                         |
|  +-----------------------------------------------------------------------------+  |
|  | Field: User.ssn                                                             |  |
|  | - Policy: Strictly Admin-Only                                               |  |
|  | - Check: currentUser.role === 'ADMIN'?                                      |  |
|  | - Evaluation: False! Unauthorized!                                          |  |
|  | - Action: Returns null for User.ssn, appends GraphQLError to 'errors' array |  |
|  +-----------------------------------------------------------------------------+  |
|                         |                                                         |
|                         v                                                         |
|  [ Response: data: { user: { id: "42", name: "Alice", email: "...", ssn: null } } |
|    errors: [ { message: "Unauthorized to access User.ssn", path: [...] } ] ]     |
+-----------------------------------------------------------------------------------+

Partial Data Masking Strategy#

A unique and powerful capability of GraphQL is Partial Data Masking. Rather than failing the entire HTTP request with a coarse 403 Forbidden, the engine returns all legitimately accessible fields in the data block, sets unauthorized fields to null, and appends specific authorization errors to the errors array.


Production Type-Safe Resolver Implementation in TypeScript#

Below is a complete, production-grade TypeScript implementation of a modular GraphQL resolver layer featuring strong context typing, asynchronous domain service delegation, and field-level access control.

typescript
// types/context.ts
import { Pool } from 'pg';
import { UserDataLoader } from '../loaders/user-loader';
import { OrderDataLoader } from '../loaders/order-loader';

export interface AuthenticatedUser {
  id: string;
  email: string;
  role: 'USER' | 'MANAGER' | 'ADMIN';
  tenantId: string;
}

export interface GraphQLContext {
  currentUser: AuthenticatedUser | null;
  db: Pool;
  requestId: string;
  loaders: {
    user: UserDataLoader;
    order: OrderDataLoader;
  };
}
typescript
// resolvers/user-resolvers.ts
import { GraphQLError } from 'graphql';
import { GraphQLContext } from '../types/context';

interface UserParent {
  id: string;
  first_name: string;
  last_name: string;
  email: string;
  ssn_encrypted?: string;
  tenant_id: string;
}

export const userResolvers = {
  Query: {
    // Top-level entry point: Query.user(id: ID!)
    user: async (
      _parent: unknown,
      args: { id: string },
      context: GraphQLContext
    ) => {
      // Authentication Guard
      if (!context.currentUser) {
        throw new GraphQLError('Authentication required to access user profiles', {
          extensions: { code: 'UNAUTHENTICATED', http: { status: 401 } }
        });
      }

      // Delegate to DataLoader for batching and caching
      const user = await context.loaders.user.load(args.id);
      if (!user) {
        throw new GraphQLError(`User with ID '{args.id}' not found`, {
          extensions: { code: 'NOT_FOUND', http: { status: 404 } }
        });
      }

      // Multi-tenant authorization barrier
      if (user.tenant_id !== context.currentUser.tenantId) {
        throw new GraphQLError('Cross-tenant data access is strictly prohibited', {
          extensions: { code: 'FORBIDDEN', http: { status: 403 } }
        });
      }

      return user;
    }
  },

  User: {
    // Computed field resolver: User.fullName
    fullName: (parent: UserParent): string => {
      return `{parent.first_name} {parent.last_name}`.trim();
    },

    // Sensitive field resolver: User.ssn
    ssn: (parent: UserParent, _args: unknown, context: GraphQLContext): string | null => {
      const user = context.currentUser;
      
      // Strict role-based authorization check
      const isSelf = user?.id === parent.id;
      const isAdmin = user?.role === 'ADMIN';

      if (!isSelf && !isAdmin) {
        // Return null and report partial error without failing parent object
        throw new GraphQLError('You lack authorization to inspect social security numbers', {
          extensions: { code: 'FORBIDDEN' }
        });
      }

      // Decrypt and return SSN
      return parent.ssn_encrypted ? decryptSSN(parent.ssn_encrypted) : null;
    },

    // Relational field resolver: User.orders
    orders: async (
      parent: UserParent,
      args: { limit?: number },
      context: GraphQLContext
    ) => {
      const limit = Math.min(Math.max(1, args.limit || 10), 50);
      return context.loaders.order.loadByUserId({ userId: parent.id, limit });
    }
  }
};

function decryptSSN(encrypted: string): string {
  // Decryption logic
  return '***-**-6789';
}

Checkpoint Practice Problems#

Apply the formal principles of resolver lifecycles, context passing, execution trees, and AST inspection to solve these comprehensive engineering problems.

Problem 1: Resolver Call Count & Execution Tree Complexity#

Consider an enterprise e-commerce GraphQL query that fetches a list of vendors, their top products, and recent reviews:

graphql
query EnterpriseCatalog {
  vendors(limit: 20) {
    id
    name
    products(limit: 10) {
      id
      title
      reviews(limit: 5) {
        id
        rating
        comment
      }
    }
  }
}

Assume that each field resolver executes an independent database lookup without batching (DataLoader).

  1. Calculate the exact total number of resolver function invocations (N_{resolvers}) required to fulfill this query document.
  2. Calculate the exact number of database network round-trips (N_{db}) triggered under naive resolver execution.
  3. If each database query introduces an average network latency of L = 4.5 ms, compute the theoretical sequential database execution time.
  4. Contrast this with the resolver invocation and database query counts when DataLoaders are introduced.

Solution:

Step 1: Calculate Resolver Invocations (N_{resolvers}):

We count resolver executions level by level:

  • Level 0 (Root):

Query.vendors(limit: 20): 1 invocation (returns 20 vendor objects).

  • Level 1 (Vendor Fields):

For each of the 20 vendors:

* Vendor.id: 20 invocations (Default resolver)

* Vendor.name: 20 invocations (Default resolver)

* Vendor.products: 20 invocations (Custom relational resolver)

Subtotal Level 1 = 20 × 3 = 60 invocations.

  • Level 2 (Product Fields):

Total products = 20 vendors × 10 products/vendor = 200 products.

For each of the 200 products:

* Product.id: 200 invocations (Default resolver)

* Product.title: 200 invocations (Default resolver)

* Product.reviews: 200 invocations (Custom relational resolver)

Subtotal Level 2 = 200 × 3 = 600 invocations.

  • Level 3 (Review Fields):

Total reviews = 200 products × 5 reviews/product = 1,000 reviews.

For each of the 1,000 reviews:

* Review.id: 1,000 invocations (Default resolver)

* Review.rating: 1,000 invocations (Default resolver)

* Review.comment: 1,000 invocations (Default resolver)

Subtotal Level 3 = 1,000 × 3 = 3,000 invocations.

Total Resolver Invocations:

N_{resolvers} = 1 + 60 + 600 + 3,000 = 3,661 resolver callsStep 2: Calculate Database Round-trips (N_{db}) Without Batching:

Database queries occur on custom relational resolvers:

  • 1 query for Query.vendors: SELECT * FROM vendors LIMIT 20; (1 query)
  • 1 query per vendor for Vendor.products: 20 queries
  • 1 query per product for Product.reviews: 200 queries

Total Database Queries:

N_{db} = 1 + 20 + 200 = 221 distinct SQL database queriesStep 3: Sequential Database Latency:

Total DB Latency = N_{db} × L = 221 × 4.5 ms = 994.5 ms ≈ 1.0 secondA single query stalls the backend thread for nearly a full second of pure database socket I/O.

Step 4: Comparison With DataLoader Batching:

With DataLoaders:

  • Query.vendors: 1 query (SELECT * FROM vendors LIMIT 20)
  • Vendor.products: 1 batched query (SELECT * FROM products WHERE vendor_id IN (...))
  • Product.reviews: 1 batched query (SELECT * FROM reviews WHERE product_id IN (...))

Total Database Queries with DataLoader = 3 queries!

Database Latency = 3 × 4.5 ms = 13.5 ms (a 98.6% reduction in database I/O latency). The number of in-memory resolver calls remains 3,661, but they execute in under 2 ms of CPU time.


Problem 2: Dynamic SQL Column Projection via `GraphQLResolveInfo`#

In high-throughput relational backends, issuing SELECT * FROM large_table wastes memory and database buffer cache bandwidth when clients request only 2 out of 50 columns.

Construct a TypeScript helper function that inspects the GraphQLResolveInfo AST node of a resolver, extracts the set of scalar field names requested on that type, maps them to database column names using an allow-list map, and returns a sanitized SQL projection string (e.g., "id, first_name, email").

Solution:

typescript
// utils/sql-projector.ts
import { GraphQLResolveInfo, FieldNode } from 'graphql';

export function getSelectedDatabaseColumns(
  info: GraphQLResolveInfo,
  columnMapping: Record<string, string>
): string {
  const selectedColumns = new Set<string>();

  // Extract the specific field node matching the current resolver
  const fieldNode = info.fieldNodes.find(
    (node) => node.name.value === info.fieldName
  );

  if (!fieldNode || !fieldNode.selectionSet) {
    // If no selection set (or leaf scalar), project fallback primary key
    return columnMapping['id'] || 'id';
  }

  for (const selection of fieldNode.selectionSet.selections) {
    if (selection.kind === 'Field') {
      const fieldName = (selection as FieldNode).name.value;

      // Ignore introspection meta-fields (__typename)
      if (fieldName.startsWith('__')) {
        continue;
      }

      // Check if GraphQL field maps to a known database column
      const dbColumn = columnMapping[fieldName];
      if (dbColumn) {
        selectedColumns.add(dbColumn);
      }
    }
  }

  // Ensure primary key is always included for entity integrity
  if (columnMapping['id']) {
    selectedColumns.add(columnMapping['id']);
  }

  if (selectedColumns.size === 0) {
    return '*';
  }

  return Array.from(selectedColumns).join(', ');
}

Usage in a resolver:

typescript
const userColumnMapping: Record<string, string> = {
  id: 'user_id',
  email: 'email_address',
  firstName: 'first_name',
  lastName: 'last_name',
  createdAt: 'created_at'
};

// Inside Query.user resolver:
const columns = getSelectedDatabaseColumns(info, userColumnMapping);
// Generates: "SELECT user_id, email_address FROM users WHERE user_id = 1"
const result = await db.query(`SELECT {columns} FROM users WHERE user_id = 1`, [args.id]);

This prevents the database from reading and transferring 48 unrequested columns across the network bus.


Problem 3: Production Type-Safe Resolver Pipeline#

Construct an Express.js middleware and Apollo Server integration that validates an incoming Bearer JWT, constructs a strongly typed GraphQLContext, and injects per-request DataLoader instances.

Solution:

typescript
// server.ts
import express, { Request, Response } from 'express';
import { ApolloServer } from '@apollo/server';
import { expressMiddleware } from '@apollo/server/express4';
import { Pool } from 'pg';
import crypto from 'node:crypto';
import { GraphQLContext, AuthenticatedUser } from './types/context';
import { UserDataLoader } from './loaders/user-loader';
import { OrderDataLoader } from './loaders/order-loader';

const dbPool = new Pool({ connectionString: process.env.DATABASE_URL });

async function verifyToken(authHeader?: string): Promise<AuthenticatedUser | null> {
  if (!authHeader || !authHeader.startsWith('Bearer ')) {
    return null;
  }

  const token = authHeader.split(' ')[1];
  try {
    // Mock JWT verification logic
    if (token === 'valid_admin_token') {
      return {
        id: 'u_admin_1',
        email: 'admin@devthesis.com',
        role: 'ADMIN',
        tenantId: 'tenant_global'
      };
    }
    return null;
  } catch {
    return null;
  }
}

export async function createGraphQLServer(typeDefs: any, resolvers: any) {
  const app = express();
  app.use(express.json());

  const server = new ApolloServer<GraphQLContext>({
    typeDefs,
    resolvers
  });

  await server.start();

  // Context factory executed on EVERY INCOMING HTTP REQUEST
  app.use(
    '/graphql',
    expressMiddleware(server, {
      context: async ({ req }: { req: Request }): Promise<GraphQLContext> => {
        const requestId = req.get('X-Request-ID') || crypto.randomUUID();
        const authHeader = req.get('Authorization');
        const currentUser = await verifyToken(authHeader);

        // FRESH, ISOLATED DATALOADERS FOR THIS SPECIFIC REQUEST
        const loaders = {
          user: new UserDataLoader(dbPool),
          order: new OrderDataLoader(dbPool)
        };

        return {
          currentUser,
          db: dbPool,
          requestId,
          loaders
        };
      }
    })
  );

  return app;
}

Through this pattern, every incoming HTTP request receives an independent, isolated GraphQLContext. DataLoaders cache within the boundary of a single request, preventing memory leaks and cross-tenant data corruption.


Problem 4: Fine-Grained Field Authorization Shield Implementation#

Implement an asynchronous middleware wrapper function applyFieldShield(resolver, rule) that wraps any GraphQL resolver function. The rule function must receive the standard 4 resolver arguments and evaluate an asynchronous boolean condition. If the condition resolves to false, the wrapper must intercept the execution, prevent the underlying resolver from running, and throw an explicit FORBIDDEN GraphQLError.

Solution:

typescript
// auth/shield.ts
import { GraphQLResolveInfo, GraphQLError } from 'graphql';
import { GraphQLContext } from '../types/context';

export type ShieldRule<TParent = any, TArgs = any> = (
  parent: TParent,
  args: TArgs,
  context: GraphQLContext,
  info: GraphQLResolveInfo
) => boolean | Promise<boolean>;

export function applyFieldShield<TParent, TArgs, TResult>(
  resolver: (
    parent: TParent,
    args: TArgs,
    context: GraphQLContext,
    info: GraphQLResolveInfo
  ) => Promise<TResult> | TResult,
  rule: ShieldRule<TParent, TArgs>
) {
  return async (
    parent: TParent,
    args: TArgs,
    context: GraphQLContext,
    info: GraphQLResolveInfo
  ): Promise<TResult> => {
    // 1. Evaluate Authorization Rule
    const isAllowed = await Promise.resolve(rule(parent, args, context, info));

    if (!isAllowed) {
      throw new GraphQLError(
        `Access Denied: You lack permissions to query field '{info.parentType.name}.{info.fieldName}'`,
        {
          extensions: {
            code: 'FORBIDDEN',
            field: `{info.parentType.name}.{info.fieldName}`
          }
        }
      );
    }

    // 2. Execute underlying business resolver if authorized
    return resolver(parent, args, context, info);
  };
}

// Common Reusable Shield Rules
export const ShieldRules = {
  isAuthenticated: (_p: any, _a: any, ctx: GraphQLContext) => {
    return ctx.currentUser !== null;
  },

  hasRole: (role: 'ADMIN' | 'MANAGER') => {
    return (_p: any, _a: any, ctx: GraphQLContext) => {
      return ctx.currentUser?.role === role;
    };
  },

  isOwnerOrAdmin: (parent: { id: string }, _a: any, ctx: GraphQLContext) => {
    return ctx.currentUser?.id === parent.id || ctx.currentUser?.role === 'ADMIN';
  }
};

Usage in schema resolvers:

typescript
User: {
  // Wrap sensitive salary resolver with Shield rule
  salaryCents: applyFieldShield(
    (parent) => parent.salary_cents,
    ShieldRules.hasRole('ADMIN')
  )
}

Through this architecture, authorization logic is cleanly decoupled from business data fetching, ensuring uniform policy enforcement and zero code duplication across the entire GraphQL graph.

Key Architectural Takeaways

  • Every field in a GraphQL query maps to a resolver taking (parent, args, context, info).
  • The context argument carries per-request authentication, authorization credentials, and data source handles.
  • GraphQL supports partial data returns: unauthorized nullable fields return null alongside top-level error entries.

Hands-on Code Playground Available

Execute, edit, and experiment with code for this topic in the live sandbox.

Practice Check • Quick Quiz

Test Your Understanding

What occurs during GraphQL query execution when a resolver throws an error on a field declared with a Non-Null modifier (e.g., balance: Float!)?

A
The field returns a string containing the error message.
B
The null value bubbles up to the nearest nullable parent object, returning partial data for siblings.
C
The entire HTTP connection is terminated with status 500.
D
The query automatically retries 3 times.