Cache Package

The @hazeljs/cache package provides powerful caching capabilities with support for multiple strategies including memory, Redis, and multi-tier caching. It includes decorators for easy method-level caching and tag-based invalidation.

Purpose

Caching is essential for building high-performance applications, but implementing it correctly can be complex. You need to handle cache invalidation, manage different storage backends, implement cache-aside patterns, and ensure data consistency. The @hazeljs/cache package solves these challenges by providing:

  • Multiple Strategies: Choose from in-memory, Redis, or multi-tier caching based on your needs
  • Decorator-Based Caching: Add caching to any method with a simple decorator
  • Tag-Based Invalidation: Invalidate related cache entries using tags instead of managing individual keys
  • Automatic TTL Management: Built-in time-to-live handling with flexible strategies
  • Cache-Aside Pattern: Built-in support for the cache-aside pattern with automatic fallback

Architecture

The package uses a strategy pattern that allows you to swap cache implementations without changing your code:

Loading diagram...

Key Components

  1. CacheService: Main service providing unified cache operations
  2. Cache Strategies: Pluggable implementations (Memory, Redis, Multi-Tier)
  3. Decorators: @Cache, @CacheKey, @CacheTTL, @CacheTags, @CacheEvict
  4. CacheManager: Manages multiple cache instances for different use cases

Advantages

1. Performance Optimization

Dramatically reduce database queries and API calls by caching frequently accessed data. Response times can improve by 10-100x for cached data.

2. Flexible Storage Options

Start with in-memory caching for development, switch to Redis for distributed systems, or use multi-tier for optimal performance and cost.

3. Developer-Friendly API

Decorator-based approach means you can add caching to any method with a single line. No need to manually implement cache logic.

4. Smart Invalidation

Tag-based invalidation allows you to invalidate related cache entries together. Update a user? Invalidate all user-related caches automatically.

5. Production Features

Includes cache warming, statistics tracking, automatic cleanup, and support for cache-aside patterns.

6. Type Safety

Full TypeScript support ensures type-safe cache operations and prevents runtime errors.

Installation

npm install @hazeljs/cache

Quick Start

Basic Setup

import { HazelModule } from '@hazeljs/core';
import { CacheModule } from '@hazeljs/cache';

@HazelModule({
  imports: [
    CacheModule.register({
      strategy: 'memory',
      ttl: 3600, // 1 hour default TTL
    }),
  ],
})
export class AppModule {}

Cache Service

Basic Usage

import { Injectable } from '@hazeljs/core';
import { CacheService } from '@hazeljs/cache';

@Injectable()
export class UserService {
  constructor(private readonly cache: CacheService) {}

  async getUser(id: string) {
    // Try to get from cache
    const cached = await this.cache.get<User>(`user:${id}`);
    if (cached) {
      return cached;
    }

    // Fetch from database
    const user = await this.fetchUserFromDb(id);

    // Store in cache
    await this.cache.set(`user:${id}`, user, 3600); // 1 hour TTL

    return user;
  }
}

Cache-Aside Pattern

Use the getOrSet method for the cache-aside pattern:

@Injectable()
export class ProductService {
  constructor(private readonly cache: CacheService) {}

  async getProduct(id: string) {
    return await this.cache.getOrSet(
      `product:${id}`,
      async () => {
        // This function is only called if cache miss
        return await this.fetchProductFromDb(id);
      },
      3600, // TTL
      ['products', 'product-list'] // Tags for invalidation
    );
  }
}

Cache Decorators

The cache package provides a comprehensive set of decorators for managing caching behavior. These decorators use metadata to configure caching at the method level, making it easy to add caching without modifying your business logic.

Understanding Cache Decorators

Cache decorators are method decorators that store caching configuration in metadata. When a method is called, the framework intercepts it, checks the cache, and either returns cached data or executes the method and caches the result.

How Cache Decorators Work:

  1. Metadata Storage: Decorators store cache configuration using reflection
  2. Method Interception: The framework wraps your method with caching logic
  3. Cache Lookup: Before execution, checks if cached data exists
  4. Result Caching: After execution, stores the result in cache
  5. Key Generation: Automatically generates cache keys from method parameters

@Cache Decorator

The @Cache decorator is the primary decorator for enabling caching on a method. It configures how the method's results should be cached.

Purpose: Automatically cache method return values with configurable TTL, key patterns, and tags.

How it works:

  • Intercepts method calls before execution
  • Generates a cache key from the method name and parameters
  • Checks cache for existing value
  • If cache hit: returns cached value (method doesn't execute)
  • If cache miss: executes method, caches result, returns value

Configuration Options:

interface CacheOptions {
  ttl?: number;              // Time-to-live in seconds (default: 3600)
  key?: string;              // Custom key pattern with {param} placeholders
  tags?: string[];           // Tags for bulk invalidation
  strategy?: string;         // Cache strategy: 'memory', 'redis', 'multi-tier'
  ttlStrategy?: string;      // 'absolute' or 'sliding' TTL
  cacheNull?: boolean;       // Cache null/undefined values (default: false)
}

Example with Detailed Explanation:

import { Controller, Get, Param } from '@hazeljs/core';
import { Cache } from '@hazeljs/cache';

@Controller('users')
export class UsersController {
  @Get(':id')
  // @Cache is a method decorator that enables caching
  @Cache({
    ttl: 3600,                    // Cache for 1 hour (3600 seconds)
    key: 'user-{id}',             // Custom key pattern
    // {id} will be replaced with the actual id parameter value
    // Resulting key: 'user-123' for id='123'
    tags: ['users'],               // Tag for bulk invalidation
    // When you invalidate 'users' tag, all entries with this tag are cleared
  })
  async getUser(@Param('id') id: string) {
    // First call: Method executes, result cached with key 'user-123'
    // Subsequent calls with same id: Returns from cache, method doesn't execute
    return await this.userService.findOne(id);
  }
}

Key Pattern Placeholders:

  • {paramName} - Replaced with the value of the parameter named paramName
  • {0}, {1} - Replaced with parameter at index 0, 1, etc.
  • Method name and class name are available as {method} and {class}

@CacheKey Decorator

Purpose: Specifies a custom cache key generation pattern. Use this when you need fine-grained control over cache keys.

How it works:

  • Overrides the default key generation
  • Supports parameter placeholders
  • Can combine multiple parameters in the key

When to use:

  • When default key generation doesn't meet your needs
  • When you need to include query parameters in the key
  • When you want to share cache across different methods

Example with Detailed Explanation:

@Get(':id')
// @CacheKey decorator specifies the key pattern
@CacheKey('user-{id}-{role}')
// This creates keys like: 'user-123-admin' or 'user-123-user'
@Cache({ ttl: 3600 })
async getUser(
  @Param('id') id: string,        // Used in key as {id}
  @Query('role') role: string     // Used in key as {role}
) {
  // Cache key will be: 'user-{id}-{role}'
  // Example: getUser('123', 'admin') → key: 'user-123-admin'
  // Different role values create different cache entries
  return await this.userService.findOne(id);
}

Key Generation Rules:

  1. Placeholders are replaced with actual parameter values
  2. If a parameter is undefined, it's replaced with 'undefined'
  3. Objects are stringified (consider using specific properties instead)
  4. Keys are case-sensitive

@CacheTTL Decorator

Purpose: Sets the time-to-live (TTL) for cached entries. This decorator is a convenience method for setting TTL without using the full @Cache options.

How it works:

  • Sets the TTL value in cache metadata
  • Can be combined with other cache decorators
  • Overrides TTL from @Cache if both are present

Example with Detailed Explanation:

@Get('popular')
// @CacheTTL is a method decorator that sets cache expiration
@CacheTTL(7200) // Cache for 2 hours (7200 seconds)
// This is equivalent to @Cache({ ttl: 7200 })
@Cache() // Still need @Cache to enable caching
async getPopularProducts() {
  // Results cached for 2 hours
  // After 2 hours, cache expires and method executes again
  return await this.productService.findPopular();
}

TTL Strategies:

  • Absolute TTL: Cache expires at a fixed time from creation
  • Sliding TTL: Cache expiration extends on each access (configured in @Cache)

@CacheTags Decorator

Purpose: Assigns tags to cache entries for bulk invalidation. Tags allow you to invalidate related cache entries together.

How it works:

  • Stores tags in cache metadata
  • When you invalidate a tag, all entries with that tag are cleared
  • Supports multiple tags per method

When to use:

  • When you need to invalidate related data together
  • When data relationships change (e.g., user update affects user list)
  • For cache warming strategies

Example with Detailed Explanation:

@Get(':id')
// @CacheTags assigns tags to this cache entry
@CacheTags(['users', 'profiles'])
// Both 'users' and 'profiles' tags are assigned
@Cache({ ttl: 3600 })
async getUserProfile(@Param('id') id: string) {
  // This entry is tagged with both 'users' and 'profiles'
  // Invalidating either tag will clear this cache entry
  return await this.userService.getProfile(id);
}

// Later, when user data changes:
@Put(':id')
@CacheEvict({ tags: ['users'] }) // Invalidates all 'users' tagged entries
async updateUser(@Param('id') id: string, @Body() data: UpdateUserDto) {
  // This will clear getUserProfile cache (and any other 'users' tagged entries)
  return await this.userService.update(id, data);
}

Tag Best Practices:

  • Use descriptive tag names: 'users', 'products', 'orders'
  • Group related data with the same tags
  • Use hierarchical tags: 'users:profiles', 'users:settings'
  • Don't over-tag: Too many tags make invalidation inefficient

@CacheEvict Decorator

Purpose: Evicts (removes) cache entries when a method executes. Used for cache invalidation when data changes.

How it works:

  • Executes before or after the method (configurable)
  • Removes cache entries matching keys or tags
  • Supports evicting all cache entries

Configuration Options:

interface CacheEvictOptions {
  keys?: string[];           // Specific keys to evict (supports patterns)
  tags?: string[];           // Tags to evict (removes all entries with these tags)
  all?: boolean;             // Evict all cache entries (use with caution)
  beforeInvocation?: boolean; // Evict before method execution (default: false)
}

Example with Detailed Explanation:

// Create operation - invalidate related caches
@Post()
// @CacheEvict removes cache entries when this method executes
@CacheEvict({ 
  tags: ['users']  // Remove all cache entries tagged with 'users'
  // This includes: user lists, user profiles, user stats, etc.
})
async createUser(@Body() createUserDto: CreateUserDto) {
  // Before or after creating user, all 'users' tagged entries are cleared
  // Next call to getUser() or getUsers() will execute and cache fresh data
  return await this.userService.create(createUserDto);
}

// Update operation - evict specific and related caches
@Put(':id')
@CacheEvict({ 
  keys: ['user-{id}'],  // Remove specific user cache
  tags: ['users']       // Also remove all 'users' tagged entries
  // This ensures both the specific user and user lists are refreshed
})
async updateUser(
  @Param('id') id: string,
  @Body() updateUserDto: UpdateUserDto
) {
  // Evicts: 'user-123' cache AND all 'users' tagged entries
  return await this.userService.update(id, updateUserDto);
}

// Delete operation - comprehensive eviction
@Delete(':id')
@CacheEvict({ 
  keys: ['user-{id}'],  // Remove specific user
  tags: ['users'],       // Remove all user-related caches
  all: true              // Also clear entire cache (optional, use carefully)
})
async deleteUser(@Param('id') id: string) {
  // Most aggressive eviction - clears specific key, tags, and optionally all cache
  return await this.userService.delete(id);
}

Eviction Timing:

  • After execution (default): Evicts after method completes successfully
  • Before execution: Set beforeInvocation: true to evict before method runs
  • Use before eviction when you want to ensure fresh data is always fetched

Combining Decorators:

You can combine multiple cache decorators for fine-grained control:

@Get(':id')
@CacheKey('user-{id}')           // Custom key pattern
@CacheTTL(7200)                  // 2 hour TTL
@CacheTags(['users', 'profiles']) // Multiple tags
@Cache()                          // Enable caching
async getUser(@Param('id') id: string) {
  // All decorators work together:
  // - Key: 'user-{id}'
  // - TTL: 7200 seconds
  // - Tags: ['users', 'profiles']
  return await this.userService.findOne(id);
}

Decorator Execution Order:

  1. @CacheEvict (if beforeInvocation: true)
  2. Cache lookup (if @Cache is present)
  3. Method execution (if cache miss)
  4. Result caching (if @Cache is present)
  5. @CacheEvict (if beforeInvocation: false)

Cache Strategies

Memory Cache

Default strategy, stores data in application memory:

CacheModule.register({
  strategy: 'memory',
  ttl: 3600,
  cleanupInterval: 60000, // Cleanup every minute
})

Redis Cache

Use Redis for distributed caching:

CacheModule.register({
  strategy: 'redis',
  redis: {
    host: 'localhost',
    port: 6379,
    password: process.env.REDIS_PASSWORD,
  },
  ttl: 3600,
})

Multi-Tier Cache

Combine memory and Redis for optimal performance:

CacheModule.register({
  strategy: 'multi-tier',
  redis: {
    host: 'localhost',
    port: 6379,
  },
  ttl: 3600,
})

Cache Manager

Manage multiple cache instances:

import { CacheManager, CacheService } from '@hazeljs/cache';

const cacheManager = new CacheManager();

// Register multiple caches
const memoryCache = new CacheService('memory');
const redisCache = new CacheService('redis', { redis: { host: 'localhost' } });

cacheManager.register('memory', memoryCache);
cacheManager.register('redis', redisCache, true); // Set as default

// Use specific cache
const userCache = cacheManager.get('memory');
await userCache.set('key', 'value');

// Use default cache
const defaultCache = cacheManager.get();
await defaultCache.set('key', 'value');

Cache Warming

Pre-populate cache with frequently accessed data:

@Injectable()
export class CacheWarmupService {
  constructor(private readonly cache: CacheService) {}

  async warmUp() {
    await this.cache.warmUp({
      keys: ['user:1', 'user:2', 'user:3'],
      fetcher: async (key: string) => {
        const userId = key.split(':')[1];
        return await this.userService.findOne(userId);
      },
      ttl: 3600,
      parallel: true, // Fetch in parallel
    });
  }
}

Cache Statistics

Monitor cache performance:

const stats = await cache.getStats();
console.log({
  hits: stats.hits,
  misses: stats.misses,
  hitRate: stats.hitRate,
  size: stats.size,
});

Complete Example

import { Injectable } from '@hazeljs/core';
import { Controller, Get, Post, Put, Delete, Param, Body } from '@hazeljs/core';
import { CacheService, Cache, CacheEvict, CacheTags } from '@hazeljs/cache';

@Injectable()
export class ProductService {
  constructor(private readonly cache: CacheService) {}

  async findOne(id: string) {
    return await this.cache.getOrSet(
      `product:${id}`,
      async () => await this.db.product.findUnique({ where: { id } }),
      3600,
      ['products']
    );
  }

  async create(data: CreateProductDto) {
    const product = await this.db.product.create({ data });
    await this.cache.invalidateTags(['products']);
    return product;
  }
}

@Controller('products')
export class ProductsController {
  constructor(private readonly productService: ProductService) {}

  @Get(':id')
  @Cache({ ttl: 3600, tags: ['products'] })
  async getProduct(@Param('id') id: string) {
    return await this.productService.findOne(id);
  }

  @Post()
  @CacheEvict({ tags: ['products'] })
  async createProduct(@Body() createProductDto: CreateProductDto) {
    return await this.productService.create(createProductDto);
  }

  @Put(':id')
  @CacheEvict({ keys: ['product:{id}'], tags: ['products'] })
  async updateProduct(
    @Param('id') id: string,
    @Body() updateProductDto: UpdateProductDto
  ) {
    return await this.productService.update(id, updateProductDto);
  }
}

Best Practices

  1. Choose the right strategy: Use memory for single-instance apps, Redis for distributed systems.

  2. Set appropriate TTLs: Balance between freshness and performance.

  3. Use tags for invalidation: Tag related cache entries for easy bulk invalidation.

  4. Cache warming: Pre-populate cache for frequently accessed data.

  5. Monitor cache stats: Track hit rates to optimize cache configuration.

What's Next?

  • Learn about Config for cache configuration
  • Explore Prisma for database integration
  • Check out AI for caching AI responses