Cache Directive (@cache)
This guide shows how to define and implement a @cache
directive that adds caching semantics to selected fields. It includes two approaches:
- Minimal in‑memory field cache (easy to start, good for demos/dev).
- Response caching with GraphQL Yoga plugin (production‑oriented, shared cache friendly).
1) Define the Directive in SDL
Add this directive to your schema and annotate fields you want to cache:
directive @cache(ttl: Int = 60, scope: CacheScope = PUBLIC) on FIELD_DEFINITION
enum CacheScope {
PUBLIC
PRIVATE
}
# Example usage
# type Query { users: [User!]! @cache(ttl: 120, scope: PUBLIC) }
Run codegen to refresh types:
npx axolotl build
2) Implement @cache using createDirectives (in‑memory field cache)
This approach wraps field resolvers and stores results in a simple in‑process cache with TTL. It’s request‑agnostic (PUBLIC vs PRIVATE is tracked but stored in the same process cache). Replace it with Redis/Memcached for multi‑instance deployments.
import { MapperKind, getDirective as getDirectiveFn } from '@graphql-tools/utils';
import type { GraphQLSchema } from 'graphql';
// A very naive in‑memory cache; swap with LRU/Redis in production
const cache = new Map<string, { value: any; expiresAt: number; scope: 'PUBLIC' | 'PRIVATE' }>();
export const cacheDirective = (schema: GraphQLSchema, getDirective: typeof getDirectiveFn) => ({
[MapperKind.OBJECT_FIELD]: (fieldConfig: any) => {
const dir = getDirective(schema, fieldConfig, 'cache')?.[0] as { ttl?: number; scope?: 'PUBLIC' | 'PRIVATE' } | undefined;
if (!dir) return fieldConfig;
const ttl = (dir.ttl ?? 60) * 1000;
const scope = (dir.scope ?? 'PUBLIC') as 'PUBLIC' | 'PRIVATE';
const originalResolve = fieldConfig.resolve ?? ((src: any, _a: any, _c: any, _i: any) => src[fieldConfig.name]);
fieldConfig.resolve = async (source: any, args: any, ctx: any, info: any) => {
// PRIVATE scope may include a user/session key to segregate cache entries
const sessionKey = scope === 'PRIVATE' ? (ctx?.user?.id || ctx?.sessionId || 'anon') : '';
const key = `${info.parentType.name}.${info.fieldName}:${JSON.stringify(args)}:${sessionKey}`;
const now = Date.now();
const hit = cache.get(key);
if (hit && hit.expiresAt > now) return hit.value;
const value = await originalResolve(source, args, ctx, info);
cache.set(key, { value, expiresAt: now + ttl, scope });
return value;
};
return fieldConfig;
},
});
Wire it into Axolotl (Yoga shown):
import { Axolotl } from '@aexol/axolotl-core';
import { graphqlYogaAdapter } from '@aexol/axolotl-graphql-yoga';
import { cacheDirective } from './cacheDirective.js';
export const { createResolvers, createDirectives, adapter } = Axolotl(graphqlYogaAdapter)();
const directives = createDirectives({
cache: cacheDirective,
});
export const server = adapter({ resolvers, directives }).server;
3) Response Caching with Yoga Plugin (recommended)
For production, prefer HTTP/Response caching with the Yoga Envelop plugin. This caches whole responses, respects scopes, and scales with external stores.
Install the plugin:
npm i @envelop/response-cache
Enable it and map @cache to cache hints:
import { Axolotl } from '@aexol/axolotl-core';
import { graphqlYogaAdapter } from '@aexol/axolotl-graphql-yoga';
import { useResponseCache } from '@envelop/response-cache';
import { MapperKind } from '@graphql-tools/utils';
export const { createResolvers, createDirectives, adapter } = Axolotl(graphqlYogaAdapter)();
// Map @cache to field extensions understood by the response-cache plugin
const cacheHintsDirective = (schema: any, getDirective: any) => ({
[MapperKind.OBJECT_FIELD]: (fieldConfig: any) => {
const dir = getDirective(schema, fieldConfig, 'cache')?.[0];
if (!dir) return fieldConfig;
const ttl = dir.ttl ?? 60; // seconds
const scope = dir.scope ?? 'PUBLIC';
fieldConfig.extensions = {
...(fieldConfig.extensions || {}),
cacheControl: {
ttl,
scope, // PUBLIC | PRIVATE
},
};
return fieldConfig;
},
});
const directives = createDirectives({ cache: cacheHintsDirective });
export const server = adapter(
{ resolvers, directives },
{
yoga: {
plugins: [
useResponseCache({
ttl: 0, // default; per-field ttl comes from cacheControl extension above
// Optionally, provide a session function to segregate PRIVATE results
session: (ctx) => ctx.request.headers.get('x-session-id') || null,
// Provide a custom store (e.g. Redis) via the plugin options for multi-instance setups
}),
],
},
},
).server;
Notes
- Choose one approach or combine: use response cache for whole-operation caching and a fine-grained field cache for hot spots.
- PRIVATE scope should segregate caches per user/session. Provide a stable session key in the plugin
session
callback. - In multi-instance deployments, replace in-memory maps with an external store (Redis/Memcached) to keep caches coherent.
- Invalidate caches on writes as needed (e.g., publish invalidation messages after mutations).
Troubleshooting
- “Directive not applied”: ensure you pass
directives
toadapter({ resolvers, directives })
and the SDL includes the directive usages. - “Type errors in directive file”:
cacheDirective
is framework-agnostic; make sure imports ofMapperKind
and types resolve. - “Cache not hit”: verify consistent args and session keys; ensure TTL is > 0 and that resolvers are pure for cached fields.