Cache

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:

schema.graphql
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.

src/cacheDirective.ts
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):

src/axolotl.ts
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:

src/axolotl.ts
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 to adapter({ resolvers, directives }) and the SDL includes the directive usages.
  • “Type errors in directive file”: cacheDirective is framework-agnostic; make sure imports of MapperKind and types resolve.
  • “Cache not hit”: verify consistent args and session keys; ensure TTL is > 0 and that resolvers are pure for cached fields.