Skip to content

GraphQL Custom Directives — Building Reusable Schema Annotations and Middleware

DodaTech Updated 2026-06-28 3 min read

In this tutorial, you will learn about Graphql Custom Directives. We cover key concepts, practical examples, and best practices to help you master this topic.

GraphQL custom directives allow you to define reusable annotations that modify schema behavior at the field, object, or operation level, enabling cross-cutting concerns without boilerplate.

What You'll Learn

  • Defining and implementing custom directives
  • Directive location types and restrictions
  • Common directive use cases

Why It Matters

Custom directives encapsulate reusable behavior. Without them, authorization, Caching, and validation logic is duplicated across resolvers. DodaTech uses custom directives for auth and logging across all GraphQL services.

Code Examples

# Custom directive definitions
directive @auth(
  requires: Role = ADMIN
) on OBJECT | FIELD_DEFINITION

directive @rateLimit(
  max: Int!
  window: Int!
) on FIELD_DEFINITION

directive @log(
  level: String = "info"
  includeArgs: Boolean = false
) on FIELD_DEFINITION

directive @uppercase on FIELD_DEFINITION
directive @truncate(maxLength: Int!) on FIELD_DEFINITION
// Apollo Server custom directive implementation
const { SchemaDirectiveVisitor } = require('apollo-server');

class RateLimitDirective extends SchemaDirectiveVisitor {
  visitFieldDefinition(field) {
    const { max, window } = this.args;
    const originalResolve = field.resolve;

    field.resolve = async function (parent, args, context, info) {
      const key = `${context.user?.id || 'anonymous'}:${info.fieldName}`;
      const current = await redis.get(key) || 0;

      if (current >= max) {
        throw new Error(`Rate limit exceeded. Max ${max} per ${window}s`);
      }

      await redis.incr(key);
      await redis.expire(key, window);

      return originalResolve.call(this, parent, args, context, info);
    };
  }
}

const schema = makeExecutableSchema({
  typeDefs,
  schemaDirectives: {
    rateLimit: RateLimitDirective,
    auth: AuthDirective,
    log: LogDirective
  }
});
# Python custom directive with Strawberry
import strawberry
from strawberry.directives import Directive

@strawberry.directive(
    locations=[DirectiveLocation.FIELD_DEFINITION]
)
def uppercase(value: str) -> str:
    return value.upper()

@strawberry.directive(
    locations=[DirectiveLocation.FIELD_DEFINITION]
)
def truncate(value: str, max_length: int) -> str:
    if len(value) > max_length:
        return value[:max_length] + '...'
    return value

Common Mistakes

1. Making Directives Too Broad

Directives should have specific locations. Avoid using OBJECT when FIELD_DEFINITION is sufficient.

2. Not Handling Directive Execution Order

Multiple directives on the same field execute in a specific order. Document the expected order.

3. Creating Directives That Mutate Arguments

Directives should not modify request arguments unexpectedly. Document side effects.

4. Ignoring Directive Performance

Directives that make database calls or API requests add latency to every field resolution.

5. Not Providing Default Values

Directive arguments should have sensible defaults where possible to simplify usage.

Practice Questions

  1. How do you define a custom directive?
  2. What is a directive location?
  3. How do directives affect field resolution?
  4. Can multiple directives be applied to the same field?
  5. How do you pass arguments to directives?

Answers:

  1. With the directive keyword: directive @name(arg: Type) on Location.
  2. The schema element where the directive can be applied (FIELD_DEFINITION, OBJECT, QUERY, etc.).
  3. Directives wrap the original resolver with custom logic before or after execution.
  4. Yes, multiple directives can be applied. Execution order depends on the implementation.
  5. Arguments are defined in parentheses after the directive name.

Challenge: Build a reusable directives library with @auth, @rateLimit, @log, @validate, and @cacheControl directives. Implement each as a class and compose multiple directives on the same field.

FAQ

Can directives be used on client-side operations?

Yes. @skip and @include are client-side execution directives. Custom execution directives are typically server-side.

How do directives interact with DataLoader?

Directives wrap field resolvers. DataLoader operates at the resolver level, so directives and DataLoader work independently.

Can I create directives that return different types?

No. Directives cannot change the return type of a field. They wrap the resolution logic.

How do I test custom directives?

Unit test the directive class methods. Integration test the directive applied to fields with mock resolvers.

Are directives in the GraphQL spec extensible?

Yes. The spec defines built-in directives and allows implementations to support custom directives.

Mini Project

Create a comprehensive directives library for a production GraphQL API. Implement @auth with role hierarchy, @rateLimit with Redis backend, @log with structured logging, @validate with field constraints, and @transform for data formatting. Include unit tests.

What's Next

Explore nested queries and resolver chains, then learn about resolver arguments in detail.

Built by the developers of DodaTech

Doda Browser, DodaZIP & Durga Antivirus Pro