Skip to content

Cache

Contract

@modularityjs/cache defines the abstract CacheService:

typescript
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.

typescript
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.

typescript
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

typescript
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:

typescript
// 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:

typescript
// 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 product and category gets 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.

typescript
// 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).

typescript
// 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.

typescript
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

typescript
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

  • cacheName defaults 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.
  • key is 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 a ValidationException at call time telling you to supply an explicit key.

@Cacheable options

OptionTypeDescription
cacheNamestringLogical cache scope: default key prefix + the auto-tag on every entry. Default <ClassName>.<methodName>.
key(args: unknown[]) => stringCache-key builder. Default `${cacheName}:${JSON.stringify(args)}`.
ttlMsnumberPassed through as CacheSetOptions.ttlMs. Default: driver default.
tagsstring[] | (args: unknown[]) => string[]Extra tags on top of the automatic cacheName tag.
condition(args: unknown[]) => booleanSkip caching entirely (straight call-through) when it returns false.
unless(result: unknown) => booleanSkip the write (result still returned) when it returns true.
ordernumberChain position among stacked method interceptors; lower = outermost. Default 100 (outermost).
typescript
@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

OptionTypeDescription
cacheNamestringMust match the @Cacheable cacheName being evicted. Default <ClassName>.<methodName>.
key(args: unknown[]) => stringSingle key to evict. Default `${cacheName}:${JSON.stringify(args)}`.
keys(args: unknown[]) => string[]Multiple keys to evict — mutually exclusive with key.
allEntriesbooleanEvict the whole cacheName scope via tag invalidation (works because @Cacheable auto-tags writes).
beforeInvocationbooleanEvict before invoking the method. Default false (evict after success only).
failOpenbooleanSwallow eviction failures (with a warning) instead of rethrowing. Default false.
ordernumberChain 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.