GraphQL

Custom Directives

18 min Lesson 17 of 35

Extending GraphQL with Custom Directives

Directives provide a way to add custom logic to your GraphQL schema without modifying types or fields. They enable reusable features like authorization, caching, formatting, and validation.

What are Schema Directives?

Schema directives are annotations prefixed with @ that modify the behavior of fields, types, or arguments. GraphQL includes built-in directives like @deprecated, @skip, and @include.

Using Built-in Directives:
type User {
  id: ID!
  name: String!
  email: String! @deprecated(reason: "Use contactEmail instead")
  contactEmail: String!
  posts: [Post!]!
}

# Client can conditionally skip fields
query GetUser($includeEmail: Boolean!) {
  user(id: "1") {
    id
    name
    email @include(if: $includeEmail)
    posts @skip(if: false)
  }
}

Declaring Custom Directives

Custom directives are defined in your schema using the directive keyword. You specify where they can be applied using directive locations.

Directive Declaration:
directive @auth(
  requires: Role = USER
) on OBJECT | FIELD_DEFINITION

directive @rateLimit(
  limit: Int = 10,
  window: Int = 60
) on FIELD_DEFINITION

directive @cache(
  maxAge: Int = 3600
) on FIELD_DEFINITION

directive @upper on FIELD_DEFINITION

directive @length(
  min: Int,
  max: Int
) on ARGUMENT_DEFINITION | INPUT_FIELD_DEFINITION

enum Role {
  USER
  ADMIN
  MODERATOR
}
Note: Directive locations include: FIELD_DEFINITION, OBJECT, INTERFACE, UNION, ENUM, SCALAR, INPUT_OBJECT, INPUT_FIELD_DEFINITION, ARGUMENT_DEFINITION, SCHEMA, and more.

Implementing @auth Directive

The @auth directive restricts field access based on user roles.

Auth Directive Implementation (Apollo):
import { SchemaDirectiveVisitor } from '@graphql-tools/utils';
import { defaultFieldResolver } from 'graphql';

class AuthDirective extends SchemaDirectiveVisitor {
  visitFieldDefinition(field) {
    const { requires } = this.args;
    const { resolve = defaultFieldResolver } = field;

    field.resolve = async function (source, args, context, info) {
      const user = context.user;

      if (!user) {
        throw new Error('Authentication required');
      }

      if (requires && user.role !== requires) {
        throw new Error(`Role ${requires} required`);
      }

      return resolve.call(this, source, args, context, info);
    };
  }
}

// Apply to schema
const schema = makeExecutableSchema({
  typeDefs,
  resolvers,
  schemaDirectives: {
    auth: AuthDirective
  }
});

Using @auth in Schema

Schema with Auth Directive:
type Query {
  # Public - no auth required
  posts: [Post!]!

  # Requires authentication
  me: User! @auth

  # Requires ADMIN role
  users: [User!]! @auth(requires: ADMIN)
  allOrders: [Order!]! @auth(requires: ADMIN)
}

type Mutation {
  # Any authenticated user
  createPost(title: String!, content: String!): Post! @auth

  # Only admins
  deleteUser(id: ID!): Boolean! @auth(requires: ADMIN)

  # Only moderators or admins
  approvePost(id: ID!): Post! @auth(requires: MODERATOR)
}

type User @auth {
  id: ID!
  email: String!
  # Even if User type is queried, sensitive field needs extra protection
  ssn: String! @auth(requires: ADMIN)
}

Implementing @rateLimit Directive

Rate limiting prevents abuse by limiting the number of requests per time window.

Rate Limit Directive:
import { SchemaDirectiveVisitor } from '@graphql-tools/utils';
import { defaultFieldResolver } from 'graphql';

class RateLimitDirective extends SchemaDirectiveVisitor {
  visitFieldDefinition(field) {
    const { limit, window } = this.args;
    const { resolve = defaultFieldResolver } = field;
    const requests = new Map();

    field.resolve = async function (source, args, context, info) {
      const userId = context.user?.id || context.ip;
      const key = `${userId}:${info.parentType}.${info.fieldName}`;
      const now = Date.now();

      // Get request history
      let history = requests.get(key) || [];

      // Remove old requests outside window
      history = history.filter(time => now - time < window * 1000);

      // Check limit
      if (history.length >= limit) {
        throw new Error(
          `Rate limit exceeded. Max ${limit} requests per ${window}s`
        );
      }

      // Add current request
      history.push(now);
      requests.set(key, history);

      return resolve.call(this, source, args, context, info);
    };
  }
}
Using Rate Limit:
type Mutation {
  # Max 5 posts per minute
  createPost(title: String!, content: String!): Post!
    @auth
    @rateLimit(limit: 5, window: 60)

  # Max 3 password resets per hour
  resetPassword(email: String!): Boolean!
    @rateLimit(limit: 3, window: 3600)
}

Implementing @cache Directive

Caching directives optimize performance by storing expensive computations.

Cache Directive Implementation:
class CacheDirective extends SchemaDirectiveVisitor {
  visitFieldDefinition(field) {
    const { maxAge } = this.args;
    const { resolve = defaultFieldResolver } = field;
    const cache = new Map();

    field.resolve = async function (source, args, context, info) {
      const key = JSON.stringify({
        field: info.fieldName,
        args,
        sourceId: source?.id
      });

      // Check cache
      const cached = cache.get(key);
      if (cached && Date.now() - cached.time < maxAge * 1000) {
        return cached.value;
      }

      // Execute resolver
      const result = await resolve.call(this, source, args, context, info);

      // Store in cache
      cache.set(key, { value: result, time: Date.now() });

      return result;
    };
  }
}
Using Cache Directive:
type Query {
  # Cache for 1 hour
  popularPosts: [Post!]! @cache(maxAge: 3600)

  # Cache for 5 minutes
  stats: SiteStats! @cache(maxAge: 300)
}

type User {
  id: ID!
  name: String!
  # Cache user posts for 10 minutes
  posts: [Post!]! @cache(maxAge: 600)
}

Transformer Directives

Transformer directives modify field values before returning them to the client.

Uppercase Transformer:
class UpperDirective extends SchemaDirectiveVisitor {
  visitFieldDefinition(field) {
    const { resolve = defaultFieldResolver } = field;

    field.resolve = async function (...args) {
      const result = await resolve.apply(this, args);
      if (typeof result === 'string') {
        return result.toUpperCase();
      }
      return result;
    };
  }
}

# Usage
type User {
  name: String! @upper  # "john doe" becomes "JOHN DOE"
  email: String!
}

Validation Directives

Validation directives enforce constraints on input arguments.

Length Validation Directive:
class LengthDirective extends SchemaDirectiveVisitor {
  visitArgumentDefinition(argument) {
    const { min, max } = this.args;

    // Wrap field resolver
    const field = argument.astNode.parent;
    const { resolve = defaultFieldResolver } = field;

    field.resolve = async function (source, args, context, info) {
      const value = args[argument.name];

      if (min && value.length < min) {
        throw new Error(
          `${argument.name} must be at least ${min} characters`
        );
      }

      if (max && value.length > max) {
        throw new Error(
          `${argument.name} must be at most ${max} characters`
        );
      }

      return resolve.call(this, source, args, context, info);
    };
  }
}

# Usage
type Mutation {
  createUser(
    username: String! @length(min: 3, max: 20)
    password: String! @length(min: 8, max: 100)
  ): User!
}
Tip: Combine multiple directives for powerful field-level control. For example: @auth @rateLimit @cache provides authentication, rate limiting, and caching in one field.
Warning: Directive execution order matters when chaining. Generally: validation → authentication → rate limiting → caching → transformation. Test directive combinations thoroughly.
Exercise:
  1. Create a @log directive that logs every field access with timestamp and user info
  2. Implement a @sanitize directive that removes HTML tags from string inputs
  3. Build a @cost directive that tracks query complexity and rejects expensive queries
  4. Create a @mask directive that partially hides sensitive data (e.g., credit card numbers)
  5. Combine @auth, @rateLimit, and @cache on a single field and test behavior