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({ maxSize: 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({ host: 'localhost', port: 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.