Skip to content

Cache API

Package: @onebun/cache

Overview

OneBun provides a caching module with support for:

  • In-memory cache
  • Redis cache
  • Module-based integration with DI

CacheModule

CacheModule is global by default — once imported in the root module, CacheService is automatically available in all submodules without explicit import. Use isGlobal: false to require an explicit import instead; see Non-Global Mode.

Basic Setup

typescript
import { Module } from '@onebun/core';
import { CacheModule, CacheType } from '@onebun/cache';
import { UserController } from './user.controller';
import { UserService } from './user.service';

// CacheModule imported once in root — CacheService available everywhere
@Module({
  imports: [
    CacheModule.forRoot({
      type: CacheType.MEMORY,  // CacheType.MEMORY or CacheType.REDIS
      cacheOptions: {
        defaultTtl: 300000,   // Default TTL in milliseconds
      },
    }),
  ],
  controllers: [UserController],
  providers: [UserService],
})
export class AppModule {}

// CacheService is automatically available in all submodules
@Module({
  controllers: [UserController],
  providers: [UserService], // UserService can inject CacheService
})
export class UserModule {}

Non-Global Mode

isGlobal: false stops CacheService from being ambiently available: a module reaches it only by importing CacheModule explicitly. There is still exactly ONE CacheService per application — the two modes differ in VISIBILITY, not in how many instances exist.

typescript
// Root module: non-global cache
@Module({
  imports: [
    CacheModule.forRoot({
      type: CacheType.REDIS,
      isGlobal: false,
    }),
  ],
})
export class AppModule {}

// Feature modules must explicitly import CacheModule
@Module({
  imports: [CacheModule.forFeature()],
  providers: [OrderService],
})
export class OrderModule {}

Multiple caches

Name each configuration with as, and let each feature module select the one it needs. The token is a symbol or a string:

typescript
export const SESSIONS = Symbol('SESSIONS_CACHE');
export const FRAGMENTS = Symbol('FRAGMENTS_CACHE');

@Module({
  imports: [
    CacheModule.forRoot({ type: CacheType.REDIS,  redisOptions: { database: 1 }, as: SESSIONS }),
    CacheModule.forRoot({ type: CacheType.MEMORY, cacheOptions: { maxSize: 1000 }, as: FRAGMENTS }),
    PageModule,
  ],
})
export class AppModule {}

// The feature selects its registration at its own boundary...
@Module({
  imports: [CacheModule.forFeature(FRAGMENTS)],
  providers: [PageService],
})
export class PageModule {}

// ...and its providers write the ordinary constructor. No @Inject and no token at the
// injection site — the module's import already decided which registration it resolves to.
@Service()
export class PageService extends BaseService {
  constructor(private cache: CacheService) { super(); }
}

Registering one token twice throws rather than silently replacing the first, and selecting a token that no forRoot() configured fails at startup naming the missing call.

One registration per module. A module that selects two registrations of the same service cannot resolve it by type — the service has a single injection identity — so it must name each one with @Inject(TOKEN). Without the annotation the application refuses to start, naming both candidates.

A named registration is never global. That is what makes two of them safe: ambient visibility has one slot per service, so a named registration reaches a module only by being imported. Combining as with isGlobal: true throws. An unnamed forRoot() keeps the global behaviour it always had.

forFeature() with no token still SHARES: every module importing the same registration receives the same CacheService, so a value written through one is visible through another and the cache is initialized once. isGlobal controls visibility, never instance count.

Redis Configuration

typescript
CacheModule.forRoot({
  type: CacheType.REDIS,
  cacheOptions: {
    defaultTtl: 300000,  // TTL in milliseconds
  },
  redisOptions: {
    host: 'localhost',
    port: 6379,
    password: 'secret',
    database: 0,
    connectTimeout: 5000,     // Connection timeout in ms
    keyPrefix: 'myapp:cache:', // Key prefix for all cache keys
  },
  // Prefer env-based configuration (see below) or use getConfig() for dynamic values
})

Environment Variable Configuration

CacheService auto-initializes from environment variables. When you import CacheModule without .forRoot(), it reads all configuration from env vars — no explicit options needed.

bash
# Cache type: 'memory' or 'redis'
CACHE_TYPE=redis

# Common options
CACHE_DEFAULT_TTL=300000        # Default TTL in ms (default: 0 = no expiry)
CACHE_MAX_SIZE=1000             # Max items for in-memory cache
CACHE_CLEANUP_INTERVAL=60000   # Cleanup interval in ms
CACHE_ALLOW_DEGRADED_START=true # Start on a process-local in-memory cache when the configured Redis
                                # is unreachable, instead of failing app.start() (default: false;
                                # only meaningful with CACHE_TYPE=redis) — see "Unreachable Redis
                                # at startup" below for what it costs

# Redis options (only used when CACHE_TYPE=redis)
CACHE_REDIS_HOST=localhost
CACHE_REDIS_PORT=6379
CACHE_REDIS_PASSWORD=secret
CACHE_REDIS_DATABASE=0
CACHE_REDIS_CONNECT_TIMEOUT=5000
CACHE_REDIS_KEY_PREFIX=myapp:cache:

Import CacheModule without .forRoot() — configuration comes entirely from env vars:

typescript
import { Module, Service, BaseService } from '@onebun/core';
import { CacheModule, CacheService } from '@onebun/cache';

// CacheModule without .forRoot() — auto-configures from env vars
@Module({
  imports: [CacheModule],
  providers: [MyService],
})
class AppModule {}

@Service()
class MyService extends BaseService {
  constructor(private cacheService: CacheService) {
    super();
  }
}

TIP

CacheModule must be imported at least once (in the root module) — it registers CacheService in the DI container. Since it's global by default, submodules get CacheService automatically. The difference is only whether you use .forRoot(options) (explicit config) or plain CacheModule (env-only config).

Configuration Priority

Configuration is resolved in this order (first wins):

  1. CacheModule.forRoot() options (explicit module configuration)
  2. Environment variables (CACHE_*)
  3. Default values (in-memory, no TTL)

Custom Environment Prefix

Use envPrefix to avoid collisions when running multiple cache instances:

typescript
CacheModule.forRoot({
  type: CacheType.REDIS,
  envPrefix: 'ORDERS_CACHE',  // Uses ORDERS_CACHE_REDIS_HOST, etc.
})

Unreachable Redis at startup

Configured means required. If the application configured type: REDIS (or CACHE_TYPE=redis) and Redis is not reachable, app.start() fails and the process never takes traffic. The failure is raised from CacheService's onModuleInit, so it arrives as a rejected app.start(), not as a log line after the server is already listening.

The connect attempt is bounded by connectTimeout (default 5000 ms). The driver's own reconnect never gives up on a host that swallows packets, so without that bound a black-holed Redis leaves the boot hanging indefinitely.

The error names the backend, the target, how long it waited and the one option that opts out. It never contains the password — the target is rebuilt from host, port and database rather than from the connection URL:

Cache backend "redis" at redis://cache.internal:6379/0 did not become usable within 5000ms
(gave up after 5003ms). Cause: Error: timed out after 5000ms. The application configured redis
explicitly, so startup fails instead of silently serving a process-local in-memory cache.
Set allowDegradedStart: true (or CACHE_ALLOW_DEGRADED_START=true) to accept a degraded cache at boot.

An application that configures nothing is unaffected: with no type and no CACHE_TYPE, the cache is in-memory by choice and there is nothing to fail.

Telling this failure apart from any other

The boot failure is not catchable by class. CacheBackendUnavailableError is not exported from @onebun/cache, and because the failure is raised from onModuleInit the rejection reaches the caller wrapped by Effect: the constructor is FiberFailureImpl and the name is prefixed, so strict equality fails too. What works today is a substring test — the message survives intact:

typescript
try {
  await app.start();
} catch (error) {
  // error.name is '(FiberFailure) CacheBackendUnavailableError'
  if (String((error as Error).name).includes('CacheBackendUnavailableError')) {
    // the configured cache backend never became usable
  }
  throw error;
}

The error instanceof EnvValidationError pattern from Environment Configuration does not transfer here: env validation runs before the module hooks and surfaces its error unwrapped, this one does not.

Once a start has succeeded under allowDegradedStart there is nothing to catch — use getBackendStatus() instead.

Accepting a degraded cache

Where a cold, unshared cache is genuinely acceptable at boot, say so:

typescript
CacheModule.forRoot({
  type: CacheType.REDIS,
  allowDegradedStart: true,  // start on a process-local cache if Redis is down
})

The same switch is readable from the environment as CACHE_ALLOW_DEGRADED_START=true (with the configured envPrefix), for deployments whose whole cache configuration comes from env vars.

Know what it buys:

  • the fallback is permanent for the life of the process — nothing retries, and a Redis that comes up three seconds later changes nothing;
  • the fallback cache is per-process — two replicas hold different data, and cross-replica invalidation silently does nothing;
  • one WARN line says exactly that, naming the configured backend and the target.

Which backend is actually serving

typescript
const status = cacheService.getBackendStatus();
// { configured: CacheType.REDIS, active: CacheType.MEMORY, degraded: true }

getBackendStatus() is synchronous and answers from state, so a readiness endpoint can call it on every probe — treat degraded: true as NOT ready. It is meaningful once initialization has settled, which the framework awaits before the application starts.

Memory Configuration

typescript
CacheModule.forRoot({
  type: CacheType.MEMORY,
  cacheOptions: {
    defaultTtl: 300000,       // TTL in milliseconds
    maxSize: 1000,            // Maximum items
    cleanupInterval: 60000,   // Cleanup every 60 seconds (ms)
  },
})

CacheService

Connection Lifecycle

CacheService implements OnModuleDestroy and closes the cache when the application stops. It does not disconnect a shared Redis client: close() disconnects only a client the service owns.

A shared client is reference-counted. Each consumer takes a hold when it first obtains the client, app.stop() releases that hold, and the connection is closed only when the last one lets go — so in multi-service mode the first sub-application to stop no longer tears the client away from its still-running siblings. app.stop({ closeSharedRedis: false }) skips releasing altogether.

If the shared client is gone when a cache operation runs, the cache re-acquires it rather than reporting a miss. When it cannot — the server is unreachable — the operation throws. A cache read returns undefined only for a key that genuinely is not there; a broken cache is an error, because code that treats a miss as "not present" (rate limits, replay guards, locks) would otherwise decide wrongly. The re-acquire is bounded by a short deadline, since the driver's auto-reconnect retries indefinitely and would otherwise turn a dead cache into a hung request.

typescript
const app = new OneBunApplication(AppModule);
await app.start();
await app.stop();               // the cache is closed
// with a shared Redis client:
await app.stop({ closeSharedRedis: false });  // the shared client stays connected

Before 0.4.5 nothing in the lifecycle called close(), so a cache built by one test suite stayed open into the next.

Injection

typescript
import { Service, BaseService } from '@onebun/core';
import { CacheService } from '@onebun/cache';

@Service()
export class UserService extends BaseService {
  constructor(private cacheService: CacheService) {
    super();
  }
}

Methods

get<T>()

Retrieve value from cache.

typescript
async get<T = unknown>(key: string): Promise<T | undefined>
typescript
const user = await this.cacheService.get<User>('user:123');

if (user) {
  // Cache hit
  return user;
}

// Cache miss

undefined is the only miss marker. A null that was written to the cache is a hit and comes back as nullhas() returns true for it — so value === null never means "absent".

set()

Store value in cache.

typescript
async set<T>(key: string, value: T, options?: CacheSetOptions): Promise<void>
typescript
// With default TTL
await this.cacheService.set('user:123', user);

// With custom TTL (in milliseconds)
await this.cacheService.set('user:123', user, { ttl: 600_000 }); // 10 minutes

// No expiration
await this.cacheService.set('user:123', user, { ttl: 0 });

delete()

Remove value from cache.

typescript
async delete(key: string): Promise<boolean>
typescript
const deleted = await this.cacheService.delete('user:123');

has()

Check if key exists.

typescript
async has(key: string): Promise<boolean>
typescript
if (await this.cacheService.has('user:123')) {
  // Key exists
}

clear()

Delete every entry in this cache's key prefix.

typescript
async clear(): Promise<void>
typescript
await this.cacheService.clear();

The scope is the prefix, and only the prefix. A cache configured with keyPrefix: 'myapp:cache:' deletes myapp:cache:* and nothing else, so sessions, queues and rate-limit counters sharing that Redis database are untouched.

A cache with no prefix refuses to clear

clear() and getStats() throw when the client they run on has an empty keyPrefix, because a cache that cannot name its own keyspace cannot delete inside it either — the pattern would be *, and the deletion would take every other tenant of the database with it. The error names the mode and what to configure.

This is reachable: CacheModule.forRoot({ redisOptions: { keyPrefix: '' } }), an explicit createRedisCache({ keyPrefix: '' }), or a RedisClient you construct without one and pass in. Environment configuration cannot reach it — an empty CACHE_REDIS_KEY_PREFIX reads as unset and falls back to the default.

Which prefix applies depends on where the client comes from:

ModePrefix that appliesKey on the wire for set('user:1', …)
Standalone (createRedisCache({ keyPrefix: 'myapp:cache:' }))the cache's own keyPrefix, passed to the client it createsmyapp:cache:user:1
Shared (useSharedClient: true)the shared client's prefix, from SharedRedisProvider.configure({ keyPrefix: 'shared:' })shared:user:1
Injected (new RedisCache(client))the prefix that client was constructed withthat client's prefix + user:1

The client is the sole owner of the prefix. It applies one on every command, prefixes the patterns clear() and getStats() scope themselves with, and strips it back off results. Nothing above it prefixes as well, because two owners that do not know about each other is precisely how myapp:cache:myapp:cache:user:1 happened.

That is also why a keyPrefix cannot be combined with useSharedClient: true — the shared client owns the keyspace, so a cache-level prefix has nowhere to go. It is rejected at construction, naming SharedRedisProvider.configure() as the place to set it. It used to be dropped in silence, so keys landed under the shared prefix alone and anything written against the configured name found nothing. A keyPrefix cannot be supplied alongside an injected client either: the constructor takes options or a client, never both.

Before 0.5.1 the standalone path applied the prefix twice — entries were stored under prefix + prefix + key — so any reader outside the cache (a runbook, SCAN, a Redis ACL key pattern, another service) looked in the wrong place. Caches warmed by an older release will miss on their first read after upgrading and refill normally.

mget<T>()

Get multiple values at once.

typescript
async mget<T = unknown>(keys: string[]): Promise<(T | undefined)[]>
typescript
const results = await this.cacheService.mget<User>([
  'user:1',
  'user:2',
  'user:3',
]);

for (const user of results) {
  if (user) {
    this.logger.info('Found user', { name: user.name });
  }
}

mset<T>()

Set multiple values at once.

typescript
async mset<T = unknown>(
  entries: Array<{ key: string; value: T; options?: CacheSetOptions }>
): Promise<void>
typescript
await this.cacheService.mset([
  { key: 'user:1', value: user1 },
  { key: 'user:2', value: user2, options: { ttl: 300_000 } },
]);

Caching Patterns

Cache-Aside Pattern

typescript
@Service()
export class UserService extends BaseService {
  constructor(
    private cacheService: CacheService,
    private repository: UserRepository,
  ) {
    super();
  }

  async findById(id: string): Promise<User | null> {
    const cacheKey = `user:${id}`;

    // Try cache first
    const cached = await this.cacheService.get<User>(cacheKey);
    if (cached) {
      this.logger.debug('Cache hit', { key: cacheKey });
      return cached;
    }

    // Cache miss - fetch from database
    this.logger.debug('Cache miss', { key: cacheKey });
    const user = await this.repository.findById(id);

    // Store in cache
    if (user) {
      await this.cacheService.set(cacheKey, user, { ttl: 300_000 }); // 5 minutes
    }

    return user;
  }
}

Cache Invalidation

typescript
@Service()
export class UserService extends BaseService {
  async update(id: string, data: UpdateUserDto): Promise<User> {
    const user = await this.repository.update(id, data);

    // Invalidate cache
    await this.cacheService.delete(`user:${id}`);

    // Also invalidate related caches
    await this.cacheService.delete('users:list');

    return user;
  }

  async delete(id: string): Promise<void> {
    await this.repository.delete(id);

    // Invalidate all related caches
    await Promise.all([
      this.cacheService.delete(`user:${id}`),
      this.cacheService.delete(`user:${id}:posts`),
      this.cacheService.delete(`user:${id}:settings`),
      this.cacheService.delete('users:list'),
    ]);
  }
}

Cache Warming

typescript
@Service()
export class CacheWarmerService extends BaseService {
  constructor(
    private cacheService: CacheService,
    private userRepository: UserRepository,
  ) {
    super();
  }

  async warmUserCache(): Promise<void> {
    this.logger.info('Warming user cache');

    const users = await this.userRepository.findAll({ limit: 1000 });

    await this.cacheService.mset(
      users.map(user => ({ key: `user:${user.id}`, value: user, options: { ttl: 3_600_000 } }))
    );

    this.logger.info('User cache warmed', { count: users.length });
  }
}

Memoization

typescript
@Service()
export class ConfigService extends BaseService {
  constructor(private cacheService: CacheService) {
    super();
  }

  async getFeatureFlags(): Promise<FeatureFlags> {
    const cacheKey = 'config:feature-flags';

    // Very long TTL for rarely changing data
    let flags = await this.cacheService.get<FeatureFlags>(cacheKey);

    if (!flags) {
      flags = await this.fetchFeatureFlags();
      await this.cacheService.set(cacheKey, flags, { ttl: 3_600_000 }); // 1 hour
    }

    return flags;
  }
}

Cache Types

CacheSetOptions

typescript
interface CacheSetOptions {
  /** Time-to-live in milliseconds. 0 for no expiration */
  ttl?: number;
}

CacheStats

typescript
interface CacheStats {
  hits: number;
  misses: number;
  entries: number;
  hitRate: number;
}

Shared Redis Connection

For applications using both cache and WebSocket (or other Redis-based features), you can share a single Redis connection:

typescript
import { SharedRedisProvider } from '@onebun/core';
import { createRedisCache, RedisCache } from '@onebun/cache';

// Configure shared Redis at app startup
SharedRedisProvider.configure({
  url: 'redis://localhost:6379',
  keyPrefix: 'myapp:',
});

// Option 1: Use shared client via options
const cache = createRedisCache({
  useSharedClient: true,
  defaultTtl: 60000,
});
await cache.connect();

// Option 2: Pass RedisClient directly  
const sharedClient = await SharedRedisProvider.getClient();
const cache = new RedisCache(sharedClient);

// Check if using shared connection
console.log(cache.isUsingSharedClient()); // true

Benefits:

  • Single connection pool for cache and WebSocket
  • Reduced memory footprint
  • Consistent key prefixing across features

Effect.js Integration

For Effect.js-based usage:

typescript
import { createCacheModule, cacheServiceTag, CacheType } from '@onebun/cache';
import { Effect, pipe } from 'effect';

// Create service
const cacheLayer = createCacheModule({
  type: CacheType.MEMORY,
  cacheOptions: {
    defaultTtl: 300000,
  },
});

// Use in Effect
const program = pipe(
  cacheServiceTag,
  Effect.flatMap((cache) =>
    Effect.promise(() => cache.get<User>('user:123'))
  ),
);

// Run
Effect.runPromise(
  Effect.provide(program, cacheLayer)
);

Complete Example

typescript
import { Module, Controller, BaseController, Service, BaseService, Get, Post, Delete, Param, Body, HttpException, type } from '@onebun/core';
import { CacheModule, CacheService, CacheType } from '@onebun/cache';

// Types
interface Product {
  id: string;
  name: string;
  price: number;
  stock: number;
}

// Service
@Service()
export class ProductService extends BaseService {
  private products = new Map<string, Product>();

  constructor(private cacheService: CacheService) {
    super();
    // Seed some data
    this.products.set('1', { id: '1', name: 'Widget', price: 9.99, stock: 100 });
    this.products.set('2', { id: '2', name: 'Gadget', price: 19.99, stock: 50 });
  }

  async findById(id: string): Promise<Product | null> {
    const cacheKey = `product:${id}`;

    // Check cache
    const cached = await this.cacheService.get<Product>(cacheKey);
    if (cached) {
      this.logger.debug('Product cache hit', { id });
      return cached;
    }

    // Fetch from "database"
    const product = this.products.get(id) || null;

    // Cache result
    if (product) {
      await this.cacheService.set(cacheKey, product, { ttl: 60_000 }); // 1 minute
    }

    return product;
  }

  async findAll(): Promise<Product[]> {
    const cacheKey = 'products:all';

    const cached = await this.cacheService.get<Product[]>(cacheKey);
    if (cached) {
      return cached;
    }

    const products = Array.from(this.products.values());
    await this.cacheService.set(cacheKey, products, { ttl: 30_000 }); // 30 seconds

    return products;
  }

  async updateStock(id: string, quantity: number): Promise<Product | null> {
    const product = this.products.get(id);
    if (!product) return null;

    product.stock += quantity;
    this.products.set(id, product);

    // Invalidate caches
    await Promise.all([
      this.cacheService.delete(`product:${id}`),
      this.cacheService.delete('products:all'),
    ]);

    return product;
  }
}

// Controller
@Controller('/products')
export class ProductController extends BaseController {
  constructor(private productService: ProductService) {
    super();
  }

  @Get('/')
  async findAll() {
    return await this.productService.findAll();
  }

  @Get('/:id')
  async findOne(@Param('id') id: string) {
    const product = await this.productService.findById(id);

    if (!product) {
      throw new HttpException(404, 'Product not found');
    }

    return product;
  }

  @Post('/:id/stock')
  async updateStock(
    @Param('id') id: string,
    @Body() body: { quantity: number },
  ) {
    const product = await this.productService.updateStock(id, body.quantity);

    if (!product) {
      throw new HttpException(404, 'Product not found');
    }

    return product;
  }
}

// Module
@Module({
  imports: [
    CacheModule.forRoot({
      type: CacheType.MEMORY,
      cacheOptions: {
        defaultTtl: 300000,
        maxSize: 1000,
      },
    }),
  ],
  controllers: [ProductController],
  providers: [ProductService],
})
export class ProductModule {}

Advanced Patterns

Released under the MPL-2.0 License.