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