Cache
Contract
@modularityjs/cache defines the abstract CacheService:
abstract class CacheService {
abstract get<T>(key: string): Promise<T | undefined>;
abstract set<T>(
key: string,
value: T,
options?: CacheSetOptions,
): Promise<void>;
abstract delete(key: string): Promise<void>;
abstract has(key: string): Promise<boolean>;
abstract invalidateTag(tag: string): Promise<void>;
abstract invalidateTags(tags: string[]): Promise<void>;
// Atomic operations
abstract compareAndSet<T>(
key: string,
expected: T | undefined,
next: T,
options?: CacheSetOptions,
): Promise<boolean>;
abstract increment(
key: string,
delta?: number,
options?: CacheSetOptions,
): Promise<number>;
abstract decrement(
key: string,
delta?: number,
options?: CacheSetOptions,
): Promise<number>;
}
interface CacheSetOptions {
ttlMs?: number;
tags?: string[];
}Drivers
Memory (@modularityjs/cache-memory)
Map-based in-memory cache with TTL support and LRU eviction (reads promote, oldest entry drops on overflow). For development and testing.
import { CacheModule } from '@modularityjs/cache';
import { CacheMemoryModule } from '@modularityjs/cache-memory';
const modules = [
CacheModule,
CacheMemoryModule,
// or with config:
CacheMemoryModule.forRoot({ maxEntries: 5000 }),
];Redis (@modularityjs/cache-redis)
Redis-backed cache with JSON serialization. Requires @modularityjs/redis.
import { CacheModule } from '@modularityjs/cache';
import { CacheRedisModule } from '@modularityjs/cache-redis';
import { RedisModule } from '@modularityjs/redis';
const modules = [
RedisModule.forRoot({ url: 'redis://localhost:6379' }),
CacheModule,
CacheRedisModule,
// or with config:
CacheRedisModule.forRoot({ defaultTtlMs: 60_000 }),
];Usage
import { CacheService } from '@modularityjs/cache';
import { Inject, Injectable } from '@modularityjs/di';
@Injectable()
class UserService {
constructor(@Inject(CacheService) private readonly cache: CacheService) {}
async getUser(id: string): Promise<User> {
const cached = await this.cache.get<User>(`user:${id}`);
if (cached) return cached;
const user = await this.fetchFromDb(id);
await this.cache.set(`user:${id}`, user, { ttlMs: 300_000 });
return user;
}
}Tags
Tags allow you to group cache entries and invalidate them together. This is useful when a change to one entity should clear all related cache entries.
Setting with Tags
Pass tags in the options to associate an entry with one or more tags:
// Cache a product — tag it with 'product' and its specific ID
await this.cache.set(`product:${id}`, product, {
ttlMs: 600_000,
tags: ['product', `product:${id}`],
});
// Cache a category page — tag it with 'product' (it shows products) and 'category'
await this.cache.set(`category:${slug}`, page, {
tags: ['product', 'category'],
});Invalidating by Tag
When a product changes, invalidate everything tagged with its ID or the general product tag:
// Invalidate one product's cache entries
await this.cache.invalidateTag(`product:${id}`);
// Invalidate ALL product-related entries (including category pages that show products)
await this.cache.invalidateTag('product');
// Invalidate multiple tags at once
await this.cache.invalidateTags(['product', 'category']);Tag Design Tips
- Use broad tags for entity types (
product,category,user) - Use specific tags for individual entities (
product:42,user:alice) - An entry can belong to multiple tags — a category page tagged with both
productandcategorygets invalidated when either changes - Entries without tags are unaffected by tag invalidation
Atomic operations
compareAndSet, increment, and decrement are atomic across cache drivers. Use them when you need a counter, a single-use guard, or a coordinated swap without read-modify-write races.
compareAndSet
Atomic swap-if-equal. Returns true when the swap happened, false if the current value didn't match expected.
// Initialize once: set value only if the key is missing
const created = await cache.compareAndSet(
'config:lock',
undefined,
'leader-id',
{
ttlMs: 30_000,
},
);
// Optimistic update: swap from old to new, retry on conflict
const current = await cache.get<Counter>('counter');
const swapped = await cache.compareAndSet('counter', current, {
...current,
n: current.n + 1,
});
if (!swapped) {
// someone else updated it between get and compareAndSet; retry
}Equality is structural via JSON serialization, so primitives and plain objects work. options.tags is ignored on atomic operations.
increment / decrement
Atomic numeric counters. The TTL is applied only on the initial create — subsequent increments preserve the existing TTL (matches Redis INCR + first-write PEXPIRE semantics).
// Failed-attempt counter (e.g. brute-force bound)
const attempts = await cache.increment(`mfa:${challengeId}:attempts`, 1, {
ttlMs: 300_000,
});
if (attempts >= 5) {
await cache.delete(`mfa:${challengeId}`);
}
// Quota decrement
const remaining = await cache.decrement(`quota:${userId}`, 1);
if (remaining < 0) {
throw new RateLimitExceededException();
}Calling increment on a non-numeric value throws a ValidationException. Tags are ignored.
Method caching
@Cacheable and @CacheEvict from @modularityjs/cache-plugins cache a method's result declaratively — built on the plugin system's method interceptors (see Plugins — Method-interceptor decorators), so the class must be a registered provider of some module.
import { Cacheable, CacheEvict } from '@modularityjs/cache-plugins';
import { Injectable } from '@modularityjs/di';
@Injectable()
class ProductService {
@Cacheable({ cacheName: 'products', ttlMs: 300_000 })
async findById(id: string): Promise<Product | undefined> {
return this.fetchFromDb(id);
}
@CacheEvict({ cacheName: 'products', allEntries: true })
async update(id: string, changes: Partial<Product>): Promise<Product> {
return this.saveToDb(id, changes);
}
}@Cacheable returns the cached result when present; otherwise it invokes the method and writes the result, tagged with the cacheName. undefined results are never cached — a cached undefined is indistinguishable from a miss.
Wiring
import { CacheModule } from '@modularityjs/cache';
import { CacheMemoryModule } from '@modularityjs/cache-memory';
import { CachePluginsModule } from '@modularityjs/cache-plugins';
import { PluginsModule } from '@modularityjs/plugins';
const modules = [
PluginsModule,
CacheModule,
CacheMemoryModule, // or CacheRedisModule
CachePluginsModule,
];Boot-time is strict: a decorated method without CachePluginsModule fails boot loudly (unwired interceptor source), and CachePluginsModule requires a cache driver. At runtime the behavior inverts — cache read/write failures degrade @Cacheable to a straight call-through, with a one-time ModularityJsCacheablePluginDegraded process warning per cacheName. A broken Redis slows you down; it doesn't take you down.
Cache names and keys
cacheNamedefaults to<ClassName>.<methodName>(e.g.ProductService.findById). It is the default key prefix and the tag automatically written on every entry — which is what@CacheEvict({ allEntries: true })invalidates.keyis a function, never a string DSL:(args) => string. The default is`${cacheName}:${JSON.stringify(args)}`. Arguments that aren't JSON-serializable (functions, symbols, bigints) throw aValidationExceptionat call time telling you to supply an explicitkey.
@Cacheable options
| Option | Type | Description |
|---|---|---|
cacheName | string | Logical cache scope: default key prefix + the auto-tag on every entry. Default <ClassName>.<methodName>. |
key | (args: unknown[]) => string | Cache-key builder. Default `${cacheName}:${JSON.stringify(args)}`. |
ttlMs | number | Passed through as CacheSetOptions.ttlMs. Default: driver default. |
tags | string[] | (args: unknown[]) => string[] | Extra tags on top of the automatic cacheName tag. |
condition | (args: unknown[]) => boolean | Skip caching entirely (straight call-through) when it returns false. |
unless | (result: unknown) => boolean | Skip the write (result still returned) when it returns true. |
order | number | Chain position among stacked method interceptors; lower = outermost. Default 100 (outermost). |
@Cacheable({
cacheName: 'search',
key: (args) => `search:${(args[0] as SearchQuery).term}`,
tags: (args) => [`tenant:${(args[0] as SearchQuery).tenantId}`],
condition: (args) => (args[0] as SearchQuery).term.length > 2,
unless: (result) => (result as SearchResult).items.length === 0,
})
async search(query: SearchQuery): Promise<SearchResult> { /* ... */ }@CacheEvict options
| Option | Type | Description |
|---|---|---|
cacheName | string | Must match the @Cacheable cacheName being evicted. Default <ClassName>.<methodName>. |
key | (args: unknown[]) => string | Single key to evict. Default `${cacheName}:${JSON.stringify(args)}`. |
keys | (args: unknown[]) => string[] | Multiple keys to evict — mutually exclusive with key. |
allEntries | boolean | Evict the whole cacheName scope via tag invalidation (works because @Cacheable auto-tags writes). |
beforeInvocation | boolean | Evict before invoking the method. Default false (evict after success only). |
failOpen | boolean | Swallow eviction failures (with a warning) instead of rethrowing. Default false. |
order | number | Chain position; lower = outermost. Default 150. |
Unlike @Cacheable, eviction failures rethrow by default — a swallowed failed evict silently serves stale data, which is worse than a failed write. failOpen: true opts out and downgrades the failure to a ModularityJsCacheEvictPluginFailed process warning.