annotify-redis
v0.2.1
Published
Redis-backed caching decorators for annotify. Spring Boot @Cacheable ergonomics, Node.js runtime.
Maintainers
Readme
annotify-redis
Redis-backed caching decorators for annotify — Spring Boot
@Cacheableergonomics, Node.js runtime.
Latest: v0.2.1 — adds a complete multi-page docs site under docs/. (v0.2.0 accidentally shipped annotify's docs by mistake; 0.2.1 fixes that. Source is identical between 0.2.0 and 0.2.1.)
annotify-redis adds three method decorators to annotify controllers:
@Cacheable('users', { ttl: 60 })— read-through cache; serve from Redis on hit, populate on miss@CachePut('users', { ttl: 300 })— always run the handler, then refresh the cache@CacheEvict('users', { key: (args) =>findOne:${args[0]}})— remove an entry after the handler runs
Plus a RedisCacheManager service backed by an injectable RedisAdapter, and a wrapController(UserController, cacheManager) helper that turns decorated methods into caching proxies.
Install
npm install annotify-redis
npm install --save-dev annotify ioredisannotify-redis declares ioredis@^5 as a runtime dependency. annotify@^0.5.0 is recommended as a peer dependency because the recommended wiring uses AppBuilder.useInterceptor() (added in annotify 0.5.0). Older versions still work — you just have to call wrapController() manually per controller (see Advanced: manual wrap below).
Requires TypeScript with experimentalDecorators: true — same constraint as annotify itself.
Quick start
// src/controllers/UserController.ts
import {
RestController, GetMapping, PostMapping, DeleteMapping,
PathVariable, RequestBody,
} from 'annotify';
import { Cacheable, CachePut, CacheEvict } from 'annotify-redis';
interface User { id: number; name: string }
const users: User[] = [{ id: 1, name: 'Ada' }, { id: 2, name: 'Grace' }];
@RestController('/users')
export class UserController {
// First call → handler runs, result cached. Subsequent calls → served from Redis.
@GetMapping('/:id')
@Cacheable('users', { ttl: 60 })
findOne(@PathVariable('id') id: string): User {
// pretend this hits a database
return users.find(u => u.id === Number(id))!;
}
@PostMapping('/')
@CachePut('users:list', { ttl: 300 })
create(@RequestBody() body: User): User {
users.push(body);
return body;
}
@DeleteMapping('/:id')
@CacheEvict('users', { allEntries: true })
remove(@PathVariable('id') id: string): { ok: boolean } {
const i = users.findIndex(u => u.id === Number(id));
if (i < 0) return { ok: false };
users.splice(i, 1);
return { ok: true };
}
}// src/main.ts
import { AppBuilder } from 'annotify';
import {
RedisCacheManager, IORedisAdapter, enableCaching,
} from 'annotify-redis';
import { UserController } from './controllers/UserController.js';
const cache = new RedisCacheManager(
new IORedisAdapter({ host: 'localhost', port: 6379 })
);
// 1. ONE LINE: install the cache interceptor on the app. Every controller
// you register from here on — past, present, or future — gets its
// decorated methods wrapped automatically. No per-class wrap call.
const app = new AppBuilder();
enableCaching(app, cache);
// 2. Register controllers (cache wiring is automatic).
app.register(UserController);
// 3. Boot.
await app.listen(3000);
// 4. Clean shutdown.
process.on('SIGTERM', () => cache.close());That's the whole shape. No wrapController(...) call. enableCaching(app, cache) does the job once, globally.
Advanced: manual wrap
If you're on annotify@<0.5.0 (which lacks useInterceptor()), you can still use annotify-redis — you just have to call wrapController for each controller manually:
import { wrapController } from 'annotify-redis';
wrapController(UserController, cache);
wrapController(ProductController, cache);
// ... for every controller
const app = new AppBuilder();
app.register(UserController);
app.register(ProductController);
await app.listen(3000);wrapController is still exported and is the right tool when you need to wrap a controller before passing it somewhere outside the AppBuilder (e.g. into a test harness, or into a different framework). For typical use, prefer enableCaching(app, cache).
Decorators
@Cacheable(cacheName, opts?)
Read-through cache. On every call:
- Compute a key (default
${methodName}:${hash(args)}). - Look it up in the named cache.
- Hit → return the cached value. The handler does NOT run.
- Miss → run the handler. If it returns a value, store it with the configured TTL (default 300s) and return it. Returning
undefinedskips the cache write — useful for "not found" responses.
@Cacheable('users', { ttl: 60, key: (args) => `user:${args[0]}` })| Option | Type | Default | Meaning |
|---|---|---|---|
| cacheName | string | — | Required. Redis key prefix. |
| ttl | number | 300 | Seconds before the entry expires. |
| key | (args, ctx) => string | hash of args | Custom key builder. Returns the part AFTER the cache name (${cacheName}:${key}). |
| condition | (args) => boolean \| Promise<boolean> | always true | Skip caching when this returns false (handler always runs). |
@CachePut(cacheName, opts?)
Always runs the handler. After it resolves, writes its return value to the cache (overwriting any existing entry at the same key). Use after writes to refresh related reads:
@PostMapping('/')
@CachePut('users', { key: (args) => `findOne:${args[0]}` })
create(@RequestBody() body: User): User { ... }Same ttl / key options as @Cacheable.
@CacheEvict(cacheName, opts?)
After (or before) the handler runs, removes entries from the cache. By default the eviction happens after a successful invocation:
@DeleteMapping('/:id')
@CacheEvict('users', { key: (args) => `findOne:${args[0]}` })
remove(@PathVariable('id') id: string): { ok: boolean } { ... }| Option | Type | Default | Meaning |
|---|---|---|---|
| cacheName | string | — | Required. |
| key | (args, ctx) => string | hash of args | The single entry to evict. Required unless allEntries is true. |
| allEntries | boolean | false | Wipe every entry in the cache (use sparingly). |
| beforeInvocation | boolean | false | Evict BEFORE running the handler. Only set true if you want the cache cleared even when the handler might throw. |
Cache manager
The RedisCacheManager is a thin layer over a RedisAdapter. You can use it directly without decorators:
const cache = new RedisCacheManager(adapter);
await cache.set('users', '1', { id: 1, name: 'Ada' }, 300);
const user = await cache.get<User>('users', '1'); // { id: 1, name: 'Ada' }
await cache.del('users', '1');
await cache.clear('users'); // wipes every entry under the 'users' cache
await cache.close(); // closes the underlying Redis connectionSwap the adapter
The adapter is just an interface:
interface RedisAdapter {
get(key: string): Promise<string | undefined>;
set(key: string, value: string, ttlSeconds: number): Promise<void>;
del(key: string): Promise<void>;
clear(pattern: string): Promise<number>;
close(): Promise<void>;
}IORedisAdapter is the default. You can plug in any Redis-compatible client — node-redis, KeyDB, DragonflyDB, or even a fake/in-memory one for tests:
class InMemoryAdapter implements RedisAdapter {
private store = new Map<string, string>();
async get(key: string) { return this.store.get(key); }
async set(key: string, value: string, ttl: number) { this.store.set(key, value); }
async del(key: string) { this.store.delete(key); }
async clear(pattern: string) {
const re = new RegExp('^' + pattern.replace(/\*/g, '.*') + '$');
let n = 0;
for (const k of [...this.store.keys()]) {
if (re.test(k)) { this.store.delete(k); n++; }
}
return n;
}
async close() { this.store.clear(); }
}Custom key builders
The default key (${methodName}:${hash(args)}) is opaque and stable. When you want predictable Redis keys, supply your own:
@Cacheable('users', {
ttl: 60,
key: (args) => `by-id:${args[0]}`, // → 'users:by-id:42'
})
@Cacheable('shards', {
key: (args, ctx) => `${ctx.methodName}:${(args[0] as User).tenant}:${args[0].id}`,
})The function receives:
args— the handler's runtime arguments, in declaration orderctx.methodName— the controller method name (e.g.'findOne')ctxcould grow with future metadata; right now it just carriesmethodName
How it works
┌───────────────────────┐
│ Controller class │
│ @Cacheable('users') │
│ findOne() { ... } │
└───────────┬───────────┘
│ decorator at module load time
▼
┌───────────────────────┐
│ WeakMap<proto, Meta> │ ← getOrCreateCacheMeta(proto)
└───────────┬───────────┘
│ wrapController() at runtime
▼
┌─────────────────────────────────────────┐
│ prototype.findOne = async (...args) │
│ cache.get → hit? return │
│ miss? → original(...args) │
│ → cache.set │
└───────────┬─────────────────────────────┘
│
▼
app.register(UserController)
annotify → user requests hit the wrapped prototype → cachedEach method is wrapped once. The original is stashed on __original_<methodName>, so calling wrapController() again on the same class is a no-op.
Caching patterns
Read-through for slow reads
@GetMapping('/:id')
@Cacheable('users', { ttl: 60 })
findOne(@PathVariable('id') id: string) {
return this.db.query('SELECT * FROM users WHERE id = $1', [id]);
}Read-through + write-through
After a create / update, refresh the cache for reads by id:
@PostMapping('/')
@CachePut('users', { key: (args) => `findOne:${(args[0] as User).id}` })
create(@RequestBody() body: User) { ... }Invalidation on delete
@DeleteMapping('/:id')
@CacheEvict('users', { key: (args) => `findOne:${args[0]}` })
remove(@PathVariable('id') id: string) { ... }Whole-cache wipe (rare)
@DeleteMapping('/flush-cache')
@CacheEvict('users', { allEntries: true })
flushUsers() { ... }Conditional caching
Skip the cache for premium user lookups when an Authorization header says the request is from an admin (admins must see fresh data):
@Cacheable('users', {
ttl: 60,
condition: async (args) => {
// Always cache for non-admin requests.
return !isAdminRequest();
},
})What's new in v0.2
enableCaching(app, cache)— replaces per-controllerwrapController()calls. Uses the newAppBuilder.useInterceptor()hook (annotify 0.5.0) so caching wires up automatically for every controller you register.- Helpful error when
enableCachingis called with an annotify version that lacksuseInterceptor— message tells the user to upgrade or fall back towrapController(). wrapControllerstill works — it's now an "advanced / manual" escape hatch, fully backward-compatible with v0.1.
Limitations (v0.2)
- One cache decorator per method. Stacking is not supported; if you accidentally stack, the precedence is
@CacheEvict>@CachePut>@Cacheable. - No SpEL-style placeholders in the key. Use a function key builder.
- No
@Caching(multi-cache annotation). - No class-level
@CacheConfigshortcut. - TTL is seconds, integer. No sub-second precision, no jitter, no automatic refresh.
- Cache stampede is not handled — under high concurrency on a missing key, the handler may run multiple times before the cache populates. Add a Lua-script fallback if you need single-flight.
Architecture notes
- Zero coupling to annotify at runtime.
wrapController()only reads the controller's prototype — it doesn't import or call into annotify. You could use it with any framework that calls methods on a class instance. - JSON storage only. Values are
JSON.stringify-ed beforeSET. Keep payloads small (< 100 KB) — Redis is fastest with small values. - Single-flight is your job. The manager does no request coalescing. If two requests miss simultaneously, both run the handler. Use Redis
SET NX+ a tiny Lua script if you need locking. - Eviction is best-effort.
@CacheEvictafter a failed write does not run. If you need stricter consistency, evict BEFORE invocation instead.
Testing
The package ships with an offline smoke test that uses an in-memory adapter (no Redis required):
npm run smokeExpected output:
[T1] @Cacheable: first call runs handler, subsequent hit cache
PASS 1st call invokes handler
PASS subsequent calls hit cache
PASS first and later results are equal
[T2] @Cacheable: different args trigger new handler runs
PASS one handler call per unique arg
[T3] @CachePut: every call writes to cache
PASS @CachePut does not short-circuit on hit
[T4] @CacheEvict: removes entries after handler runs
PASS handler runs again after evict
[T5] wrapController handles repeated calls
PASS handler runs exactly once per call
[T6] defaultKeyBuilder is deterministic
PASS same args → same key
PASS different args → different key
=== ALL CHECKS PASSED ===To verify against a real Redis:
# Terminal 1: start redis
docker run --rm -p 6379:6379 redis:7-alpine
# Terminal 2: start the example
npm run build
node examples/cached.controller.js # see examples/cached.controller.ts
# ... send a request, watch Redis with `redis-cli MONITOR`Project structure
annotify-redis/
├── src/
│ ├── index.ts ← public barrel
│ ├── decorators/
│ │ ├── cacheable.ts ← @Cacheable
│ │ ├── cache-put.ts ← @CachePut
│ │ ├── cache-evict.ts ← @CacheEvict
│ │ ├── metadata.ts ← WeakMap side-channel
│ │ └── method-decorator.ts ← TS decorator type alias
│ ├── cache/
│ │ ├── adapter.ts ← RedisAdapter interface
│ │ ├── manager.ts ← RedisCacheManager
│ │ ├── key-builder.ts ← default key builder
│ │ └── ioredis-adapter.ts ← default adapter (ioredis)
│ └── wrap/
│ └── wrap-controller.ts ← the wrapController helper
├── examples/
│ └── cached.controller.ts ← sample using all 3 decorators
├── smoke.ts ← offline smoke (no Redis needed)
├── tsconfig.json ← build config
├── tsconfig.smoke.json ← smoke-specific build
├── package.json
├── README.md
└── LICENSEAuthor
Sanjay Sokal — sanjaysokal.com
Links
- npm: https://www.npmjs.com/package/annotify-redis
- Companion package: annotify
License
MIT
