Directives
Directives are a powerful way to add cross-cutting concerns to your GraphQL schema. Use them for authentication, authorization, caching, rate limiting, and more.
Axolotl supports directives through the GraphQL Yoga adapter using @graphql-tools/schema.
Basic Directive Example
src/directives.ts
import { createDirectives } from '@/src/axolotl.js';
import { MapperKind } from '@graphql-tools/utils';
import { defaultFieldResolver, GraphQLError } from 'graphql';
import { YogaInitialContext } from 'graphql-yoga';
export default createDirectives({
// Directive function receives (schema, getDirective) and returns mapper config
auth: (schema, getDirective) => {
return {
[MapperKind.OBJECT_FIELD]: (fieldConfig) => {
// Check if this field has the @auth directive
const authDirective = getDirective(schema, fieldConfig, 'auth')?.[0];
if (authDirective) {
const { resolve = defaultFieldResolver } = fieldConfig;
return {
...fieldConfig,
resolve: async (source, args, context: YogaInitialContext, info) => {
// Check authentication
const token = context.request.headers.get('authorization');
if (!token) {
throw new GraphQLError('Not authenticated', {
extensions: { code: 'UNAUTHORIZED' },
});
}
// Call original resolver
return resolve(source, args, context, info);
},
};
}
return fieldConfig;
},
};
},
});Using the Directive in Schema
schema.graphql
directive @auth on FIELD_DEFINITION
type Query {
publicData: String!
protectedData: String! @auth
}Wiring Up Directives
src/index.ts
import { adapter } from '@/src/axolotl.js';
import resolvers from '@/src/resolvers.js';
import directives from '@/src/directives.js';
adapter({
resolvers,
directives, // ✅ Add directives here
}).server.listen(4000);Common Directive Patterns
1. Authentication Directive
Check if user is logged in:
import { createDirectives } from '@/src/axolotl.js';
import { MapperKind } from '@graphql-tools/utils';
import { defaultFieldResolver, GraphQLError } from 'graphql';
export default createDirectives({
auth: (schema, getDirective) => {
return {
[MapperKind.OBJECT_FIELD]: (fieldConfig) => {
const authDirective = getDirective(schema, fieldConfig, 'auth')?.[0];
if (authDirective) {
const { resolve = defaultFieldResolver } = fieldConfig;
return {
...fieldConfig,
resolve: async (source, args, context, info) => {
if (!context.userId) {
throw new GraphQLError('Not authenticated', {
extensions: { code: 'UNAUTHORIZED' },
});
}
return resolve(source, args, context, info);
},
};
}
return fieldConfig;
},
};
},
});Usage:
type Query {
me: User! @auth
myPosts: [Post!]! @auth
}
type Mutation {
createPost(input: CreatePostInput!): Post! @auth
}2. Role-Based Authorization
Check if user has required role:
export default createDirectives({
hasRole: (schema, getDirective) => {
return {
[MapperKind.OBJECT_FIELD]: (fieldConfig) => {
const roleDirective = getDirective(schema, fieldConfig, 'hasRole')?.[0];
if (roleDirective) {
const { role } = roleDirective;
const { resolve = defaultFieldResolver } = fieldConfig;
return {
...fieldConfig,
resolve: async (source, args, context, info) => {
if (!context.userId) {
throw new GraphQLError('Not authenticated', {
extensions: { code: 'UNAUTHORIZED' },
});
}
if (!context.userRoles?.includes(role)) {
throw new GraphQLError('Insufficient permissions', {
extensions: {
code: 'FORBIDDEN',
requiredRole: role,
},
});
}
return resolve(source, args, context, info);
},
};
}
return fieldConfig;
},
};
},
});Schema:
directive @hasRole(role: String!) on FIELD_DEFINITION
type Mutation {
deleteUser(id: ID!): Boolean! @hasRole(role: "ADMIN")
banUser(id: ID!): Boolean! @hasRole(role: "MODERATOR")
}3. Rate Limiting
Limit requests per user:
import { createDirectives } from '@/src/axolotl.js';
import { MapperKind } from '@graphql-tools/utils';
import { defaultFieldResolver, GraphQLError } from 'graphql';
// Simple in-memory store (use Redis in production)
const rateLimitStore = new Map<string, { count: number; resetAt: number }>();
export default createDirectives({
rateLimit: (schema, getDirective) => {
return {
[MapperKind.OBJECT_FIELD]: (fieldConfig) => {
const rateLimitDirective = getDirective(schema, fieldConfig, 'rateLimit')?.[0];
if (rateLimitDirective) {
const { limit = 10, window = 60 } = rateLimitDirective; // requests per window (seconds)
const { resolve = defaultFieldResolver } = fieldConfig;
return {
...fieldConfig,
resolve: async (source, args, context, info) => {
const userId = context.userId || context.request.headers.get('x-forwarded-for') || 'anonymous';
const key = `${info.fieldName}:${userId}`;
const now = Date.now();
let record = rateLimitStore.get(key);
if (!record || now > record.resetAt) {
record = {
count: 0,
resetAt: now + window * 1000,
};
}
record.count++;
rateLimitStore.set(key, record);
if (record.count > limit) {
const resetInSeconds = Math.ceil((record.resetAt - now) / 1000);
throw new GraphQLError('Rate limit exceeded', {
extensions: {
code: 'RATE_LIMIT_EXCEEDED',
limit,
resetInSeconds,
},
});
}
return resolve(source, args, context, info);
},
};
}
return fieldConfig;
},
};
},
});Schema:
directive @rateLimit(limit: Int = 10, window: Int = 60) on FIELD_DEFINITION
type Mutation {
sendEmail(to: String!, subject: String!): Boolean! @rateLimit(limit: 5, window: 3600)
createPost(input: CreatePostInput!): Post! @rateLimit(limit: 10, window: 60)
}4. Field-Level Caching
Cache resolver results:
import { createDirectives } from '@/src/axolotl.js';
import { MapperKind } from '@graphql-tools/utils';
import { defaultFieldResolver } from 'graphql';
const cache = new Map<string, { value: any; expiresAt: number }>();
export default createDirectives({
cache: (schema, getDirective) => {
return {
[MapperKind.OBJECT_FIELD]: (fieldConfig) => {
const cacheDirective = getDirective(schema, fieldConfig, 'cache')?.[0];
if (cacheDirective) {
const { ttl = 60 } = cacheDirective; // TTL in seconds
const { resolve = defaultFieldResolver } = fieldConfig;
return {
...fieldConfig,
resolve: async (source, args, context, info) => {
const key = `${info.fieldName}:${JSON.stringify(args)}`;
const now = Date.now();
// Check cache
const cached = cache.get(key);
if (cached && now < cached.expiresAt) {
return cached.value;
}
// Resolve and cache
const result = await resolve(source, args, context, info);
cache.set(key, {
value: result,
expiresAt: now + ttl * 1000,
});
return result;
},
};
}
return fieldConfig;
},
};
},
});Schema:
directive @cache(ttl: Int = 60) on FIELD_DEFINITION
type Query {
expensiveQuery: [Result!]! @cache(ttl: 300)
userStats(userId: ID!): UserStats! @cache(ttl: 60)
}5. Input Validation
Validate field arguments:
export default createDirectives({
validate: (schema, getDirective) => {
return {
[MapperKind.OBJECT_FIELD]: (fieldConfig) => {
const validateDirective = getDirective(schema, fieldConfig, 'validate')?.[0];
if (validateDirective) {
const { pattern, minLength, maxLength } = validateDirective;
const { resolve = defaultFieldResolver } = fieldConfig;
return {
...fieldConfig,
resolve: async (source, args, context, info) => {
// Validate based on directive args
for (const [key, value] of Object.entries(args)) {
if (typeof value === 'string') {
if (minLength && value.length < minLength) {
throw new GraphQLError(`${key} must be at least ${minLength} characters`);
}
if (maxLength && value.length > maxLength) {
throw new GraphQLError(`${key} must be at most ${maxLength} characters`);
}
if (pattern && !new RegExp(pattern).test(value)) {
throw new GraphQLError(`${key} format is invalid`);
}
}
}
return resolve(source, args, context, info);
},
};
}
return fieldConfig;
},
};
},
});Combining Multiple Directives
You can use multiple directives on the same field:
type Mutation {
createPost(input: CreatePostInput!): Post!
@auth
@rateLimit(limit: 10, window: 60)
@validate(minLength: 10, maxLength: 1000)
}Create all directives in one file:
src/directives.ts
import { createDirectives } from '@/src/axolotl.js';
import { MapperKind } from '@graphql-tools/utils';
export default createDirectives({
auth: (schema, getDirective) => {
/* ... */
},
hasRole: (schema, getDirective) => {
/* ... */
},
rateLimit: (schema, getDirective) => {
/* ... */
},
cache: (schema, getDirective) => {
/* ... */
},
validate: (schema, getDirective) => {
/* ... */
},
});When to Use Directives vs Resolvers
Use Directives For:
- ✅ Cross-cutting concerns (auth, logging, caching)
- ✅ Reusable logic across many fields
- ✅ Schema-level metadata
- ✅ Validation rules
Use Resolvers For:
- ✅ Business logic
- ✅ Data fetching
- ✅ Field-specific transformations
- ✅ Complex operations
Testing Directives
Test directives by creating a test schema:
import { test } from 'node:test';
import assert from 'node:assert';
import { makeExecutableSchema } from '@graphql-tools/schema';
import { mapSchema } from '@graphql-tools/utils';
import { graphql } from 'graphql';
import directives from './directives.js';
test('auth directive blocks unauthenticated requests', async () => {
const typeDefs = `
directive @auth on FIELD_DEFINITION
type Query {
protected: String! @auth
}
`;
const resolvers = {
Query: {
protected: () => 'secret',
},
};
let schema = makeExecutableSchema({ typeDefs, resolvers });
// Apply the directive transformation
const authMapper = directives.auth(schema, (schema, field, directiveName) =>
schema.getDirectives().find((d) => d.name === directiveName),
);
schema = mapSchema(schema, authMapper);
const result = await graphql({
schema,
source: '{ protected }',
contextValue: { userId: null },
});
assert(result.errors);
assert(result.errors[0].message.includes('Not authenticated'));
});Best Practices
- Keep directives simple - Complex logic belongs in resolvers
- Use clear names -
@auth,@hasRole, not@checkor@verify - Add proper error codes - Use
extensions.codefor client handling - Document directives - Add comments to schema directive definitions
- Test edge cases - Especially auth and validation directives
- Consider performance - Directives run on every request
- Use TypeScript - Type your directive arguments and context
Production Considerations
Use Redis for Rate Limiting
import Redis from 'ioredis';
const redis = new Redis(process.env.REDIS_URL);
// In your rateLimit directive:
const key = `ratelimit:${info.fieldName}:${userId}`;
const count = await redis.incr(key);
if (count === 1) {
await redis.expire(key, window);
}
if (count > limit) {
throw new GraphQLError('Rate limit exceeded');
}Use Redis for Caching
// In your cache directive:
const cached = await redis.get(key);
if (cached) {
return JSON.parse(cached);
}
const result = await resolve(source, args, context, info);
await redis.setex(key, ttl, JSON.stringify(result));
return result;Log Directive Usage
// Add logging to directives
console.log(`Directive @${directiveName} executed`, {
field: info.fieldName,
userId: context.userId,
args,
});Resources
- GraphQL Tools Documentation (opens in a new tab)
- GraphQL Directive Spec (opens in a new tab)
- Axolotl Examples (opens in a new tab)
Next Steps
- Learn about Data Loaders for performance
- Explore Recipes for common patterns
- Check out Best Practices for production apps