Skip to content

Cache Wiring

Recipes are working wiring examples with the sharp edges annotated. This page and the cache-wiring recipe served to coding agents by the MCP server share one source (@modularityjs/manifest), so they never drift apart.

Two packages compose:

  • @modularityjs/cacheCacheService abstract contract + CacheModule. Methods: get<T>(key) → T | undefined, set<T>(key, value, options?: { ttlMs?, tags? }), has(key) → boolean, delete(key), invalidateTag(tag), invalidateTags(tags[]), and atomic operations compareAndSet<T>(key, expected, next, options?) → boolean, increment(key, delta?, options?) → number, decrement(key, delta?, options?) → number. Atomic ops are race-free across instances when the active driver is cache-redis (Lua scripts) and within a process for cache-memory. Use them for counters, single-use guards, brute-force attempt caps, optimistic swaps; tags are ignored on atomic operations.
  • @modularityjs/cache-{memory,redis} — driver. Memory uses an in-process Map; Redis goes through RedisModule (also required in the modules array for the Redis driver). Both bind via preferences: [{ provide: CacheService, useClass: ... }].

get returns T | undefined — missing keys are a routine branch, not an error. set is fire-and-forget: no return value, no confirmation. ttlMs is per-entry; without it the entry lives until evicted or invalidated.

Tags are the cache-invalidation primitive. set(key, value, { tags: ['user:123'] }) stores the key under the listed tags; invalidateTag('user:123') evicts every key tagged that way in one call. Use tags when one logical resource maps to many cache keys (the same user appearing on multiple list views) — otherwise key-by-key delete is simpler.

typescript
import { CacheModule, CacheService } from '@modularityjs/cache';
import { CacheMemoryModule } from '@modularityjs/cache-memory';
import { Inject, Injectable } from '@modularityjs/di';
import { inversify } from '@modularityjs/di-inversify';
import { createApp, Module, ModularityModule } from '@modularityjs/modularity';

@Injectable()
class UserProfileService {
  constructor(@Inject(CacheService) private readonly cache: CacheService) {}

  async load(userId: string): Promise<UserProfile> {
    const key = `users:${userId}:profile`;
    const cached = await this.cache.get<UserProfile>(key);
    if (cached) return cached;
    const profile = await this.fetchFromDb(userId);
    await this.cache.set(key, profile, {
      ttlMs: 60_000,
      tags: [`user:${userId}`],
    });
    return profile;
  }

  async invalidate(userId: string): Promise<void> {
    await this.cache.invalidateTag(`user:${userId}`);
  }

  private async fetchFromDb(_userId: string): Promise<UserProfile> {
    return { name: 'placeholder' };
  }
}

interface UserProfile {
  name: string;
}

@Module({
  name: 'user-profiles',
  imports: [CacheModule],
  providers: [UserProfileService],
})
class AppModule {}

const app = await createApp({
  di: inversify,
  modules: [ModularityModule, CacheModule, CacheMemoryModule, AppModule],
});

Switch to Redis with one line. Replace CacheMemoryModule with RedisModule.forRoot({ url: '...' }), CacheRedisModuleUserProfileService is unchanged. (Redis needs its own contract module imported because the driver depends on RedisModule.)

Eviction in the memory driver. Without CacheMemoryModule.forRoot({ evictionIntervalMs }), expired entries stay in memory until next accessed (lazy sweep on get). For a long-running process with high churn, configure an interval (e.g. 30_000) so the periodic sweep reclaims memory.