Skip to main content
You can use the @cache decorator to automatically cache the results of Model or Frame methods.

Basic Usage

Simple Example

import { BaseModel, cache, api } from "sonamu";

class UserModelClass extends BaseModel {
  @cache({ ttl: "10m" })
  @api()
  async findById(id: number) {
    // This method is cached for 10 minutes
    return this.findOne(['id', id]);
  }
}
How It Works:
  1. First call: DB query → Cache result
  2. Second call (within 10 minutes): Return from cache (no DB query)
  3. After 10 minutes: Query DB again → Refresh cache

Decorator Options

All Options

@cache({
  key?: string | ((...args: unknown[]) => string),  // Cache key
  store?: string,           // Store to use
  ttl?: string | number,    // Expiration time
  grace?: string | number,  // Grace period (Stale-While-Revalidate)
  tags?: string[],          // Tag list
  forceFresh?: boolean,     // Ignore cache and force refresh
})

key: Cache Key Configuration

If no key is specified, it is automatically generated.
class UserModelClass extends BaseModel {
  @cache({ ttl: "10m" })
  async findById(id: number) {
    return this.findOne(['id', id]);
  }
}

// Call: userModel.findById(123)
// Auto-generated key: "User.findById:123"
Pattern: ModelName.methodName:serializedArgsArgument Serialization Rules:
  • Single primitive (string/number/boolean): Used as-is
  • Complex objects: Uses JSON.stringify
  • No arguments: ModelName.methodName without suffix

ttl: Expiration Time

TTL (Time To Live) is the duration the cache remains valid.
// String format (recommended)
@cache({ ttl: "10s" })   // 10 seconds
@cache({ ttl: "5m" })    // 5 minutes
@cache({ ttl: "1h" })    // 1 hour
@cache({ ttl: "1d" })    // 1 day
@cache({ ttl: "1w" })    // 1 week
@cache({ ttl: "forever" }) // Permanent

// Number format (milliseconds)
@cache({ ttl: 60000 })  // 60000ms = 1 minute
Without TTL: BentoCache default value applies (typically unlimited)

grace: Stale-While-Revalidate

Grace period is a feature that returns stale cache after TTL expiration while refreshing in the background.
@cache({
  ttl: "1m",      // Expires after 1 minute
  grace: "10m"    // Return stale value for 10 minutes after expiration
})
async getExpensiveData() {
  // Long-running operation
  return await this.heavyComputation();
}
How It Works:
  1. 0~1 minute: Return fresh cache
  2. 1~11 minutes: Immediately return stale cache + background refresh
  3. After 11 minutes: Cache miss, recalculate
Advantages:
  • Users always get fast response (even if stale, returns immediately)
  • Refreshed in background so next user gets fresh data

tags: Tag-based Invalidation

You can use tags to invalidate related caches as a group.
class ProductModelClass extends BaseModel {
  @cache({ ttl: "1h", tags: ["product", "list"] })
  @api()
  async findAll() {
    return this.findMany({});
  }

  @cache({ ttl: "1h", tags: ["product"] })
  @api()
  async findById(id: number) {
    return this.findOne(['id', id]);
  }

  @api()
  async update(id: number, data: Partial<Product>) {
    const result = await this.updateOne(['id', id], data);

    // Invalidate all caches with product tag
    await Sonamu.cache.deleteByTag({ tags: ["product"] });

    return result;
  }
}
Invalidation Pattern:
// When update() is called
await Sonamu.cache.deleteByTag({ tags: ["product"] });

// Result: Both findAll() and findById() caches are deleted
For more details, see Cache Invalidation.

store: Using a Specific Store

When multiple stores are configured, you can specify a particular store.
// Define multiple stores in sonamu.config.ts
export const config: SonamuConfig = {
  server: {
    cache: {
      default: "api",
      stores: {
        api: store().useL1Layer(drivers.memory({ maxSize: "200mb" })),
        database: store()
          .useL1Layer(drivers.memory({ maxSize: "100mb" }))
          .useL2Layer(drivers.redis({ connection: redis })),
      },
    },
  },
};

// Specify store in Model
class UserModelClass extends BaseModel {
  // Use api store (default)
  @cache({ ttl: "5m" })
  @api()
  async findAll() {
    return this.findMany({});
  }

  // Use database store (Redis shared)
  @cache({ store: "database", ttl: "1h" })
  @api()
  async findById(id: number) {
    return this.findOne(['id', id]);
  }
}

forceFresh: Ignore Cache

Use when you always want to fetch fresh data.
@cache({
  ttl: "1h",
  forceFresh: true  // Ignore cache and always run factory
})
async getRealTimeData() {
  return await this.fetchFromExternalAPI();
}
Use Case: Only for debugging or special cases (generally unnecessary)

Practical Examples

1. API Response Caching

class PostModelClass extends BaseModel {
  // Post list (5 minute cache)
  @cache({ ttl: "5m", tags: ["post", "list"] })
  @api()
  async findAll(page: number = 1) {
    return this.findMany({
      num: 20,
      page,
      order: [['created_at', 'desc']]
    });
  }

  // Post detail (10 minute cache)
  @cache({ ttl: "10m", tags: ["post"] })
  @api()
  async findById(id: number) {
    return this.findOne(['id', id]);
  }

  // Popular posts (1 hour cache)
  @cache({
    key: "popular-posts",
    ttl: "1h",
    tags: ["post", "popular"]
  })
  @api()
  async findPopular() {
    return this.findMany({
      num: 10,
      where: [['view_count', '>', 1000]],
      order: [['view_count', 'desc']]
    });
  }
}

2. Cache Invalidation on Data Change

class ProductModelClass extends BaseModel {
  @cache({ ttl: "1h", tags: ["product"] })
  @api()
  async findById(id: number) {
    return this.findOne(['id', id]);
  }

  @cache({ ttl: "30m", tags: ["product", "list"] })
  @api()
  async findAll() {
    return this.findMany({});
  }

  @api()
  async create(data: ProductSave) {
    const result = await this.saveOne(data);

    // Invalidate only list tag (new item added to list)
    await Sonamu.cache.deleteByTag({ tags: ["list"] });

    return result;
  }

  @api()
  async update(id: number, data: Partial<ProductSave>) {
    const result = await this.updateOne(['id', id], data);

    // Invalidate all product tags (detail + list)
    await Sonamu.cache.deleteByTag({ tags: ["product"] });

    return result;
  }
}

3. Complex Key Generation

class OrderModelClass extends BaseModel {
  @cache({
    key: (userId: number, status: string, startDate: string) =>
      `order:user:${userId}:${status}:${startDate}`,
    ttl: "10m",
    tags: ["order"]
  })
  @api()
  async findByUserAndStatus(
    userId: number,
    status: string,
    startDate: string
  ) {
    return this.findMany({
      where: [
        ['user_id', userId],
        ['status', status],
        ['created_at', '>=', startDate]
      ]
    });
  }
}

// Call: model.findByUserAndStatus(123, "pending", "2025-01-01")
// Key: "order:user:123:pending:2025-01-01"

4. Using Stale-While-Revalidate

class AnalyticsModelClass extends BaseModel {
  @cache({
    ttl: "5m",      // Expires after 5 minutes
    grace: "1h",    // Use stale value for 1 hour
    tags: ["analytics"]
  })
  @api()
  async getDashboardStats() {
    // Heavy aggregation query
    const [users, orders, revenue] = await Promise.all([
      this.countUsers(),
      this.countOrders(),
      this.calculateRevenue(),
    ]);

    return { users, orders, revenue };
  }
}
Scenario:
  • 0~5 minutes: Fresh cache
  • 5~65 minutes: Immediately return stale cache + background recalculation
  • After 65 minutes: Cache miss, recalculate

5. Permanent Caching for Configuration Values

class ConfigModelClass extends BaseModel {
  @cache({
    key: "app-config",
    ttl: "forever",  // Permanent caching
    tags: ["config"]
  })
  @api()
  async getAppConfig() {
    return this.findOne(['key', 'app_config']);
  }

  @api()
  async updateConfig(data: ConfigSave) {
    const result = await this.saveOne(data);

    // Invalidate cache on config change
    await Sonamu.cache.deleteByTag({ tags: ["config"] });

    return result;
  }
}

Internal Method Calls and Cache Sharing

Cache is also shared when calling other methods within a Model.
class UserModelClass extends BaseModel {
  // Apply cache to findMany
  @cache({ ttl: "10m", tags: ["user"] })
  @api()
  async findMany(params: FindManyParams<User>) {
    return super.findMany(params);
  }

  // findById internally calls findMany
  async findById(id: number) {
    const { rows } = await this.findMany({
      where: [['id', id]],
      num: 1
    });
    return rows[0];
  }
}
How It Works:
// First call
await userModel.findById(123);
// → Calls findMany({ where: [['id', 123]], num: 1 })
// → DB query and caching

// Second call (same parameters)
await userModel.findById(123);
// → Calls findMany({ where: [['id', 123]], num: 1 })
// → Return from cache (no DB query)

// Direct findMany call also uses same cache
await userModel.findMany({ where: [['id', 123]], num: 1 });
// → Return from cache

Cache Key Generation Logic

Argument Serialization

// Inside decorator.ts
function serializeArgs(args: unknown[]): string {
  if (args.length === 0) return "";

  // Single primitive value
  if (args.length === 1) {
    const arg = args[0];
    if (arg === null || arg === undefined) return "";
    if (typeof arg === "string" || typeof arg === "number" || typeof arg === "boolean") {
      return String(arg);
    }
  }

  // Complex values are JSON serialized
  try {
    return JSON.stringify(args);
  } catch {
    // Use toString on serialization failure
    return args.map((arg) => String(arg)).join(":");
  }
}
Examples:
// Single primitive
serializeArgs([123]) → "123"
serializeArgs(["hello"]) → "hello"

// Complex object
serializeArgs([{ id: 1, name: "test" }]) → '[{"id":1,"name":"test"}]'

// Multiple arguments
serializeArgs([123, "active"]) → '[123,"active"]'

Full Key Generation

function generateCacheKey(
  modelName: string,
  methodName: string,
  args: unknown[],
  keyOption?: string | Function,
): string {
  // 1. Custom key function
  if (typeof keyOption === "function") {
    return keyOption(...args);
  }

  // 2. String key + args suffix
  if (typeof keyOption === "string") {
    const argsSuffix = serializeArgs(args);
    return argsSuffix ? `${keyOption}:${argsSuffix}` : keyOption;
  }

  // 3. Auto-generated
  const baseKey = `${modelName}.${methodName}`;
  const argsSuffix = serializeArgs(args);
  return argsSuffix ? `${baseKey}:${argsSuffix}` : baseKey;
}

Direct Cache Manipulation

You can also manipulate the cache directly without decorators.
import { Sonamu } from "sonamu";

class UserModelClass extends BaseModel {
  @api()
  async findById(id: number) {
    const cacheKey = `user:${id}`;

    // Check cache
    const cached = await Sonamu.cache.get({ key: cacheKey });
    if (cached) {
      return cached;
    }

    // DB query
    const user = await this.findOne(['id', id]);

    // Cache
    await Sonamu.cache.set({
      key: cacheKey,
      value: user,
      ttl: "10m",
      tags: ["user"]
    });

    return user;
  }
}
Decorator vs Direct Manipulation:
  • Decorator: Concise, declarative, automatic key generation
  • Direct Manipulation: Complex logic, conditional caching, fine-grained control

Cautions

@cache Decorator Cautions:
  1. Cache Manager Initialization Required: Error if no cache configuration in sonamu.config.ts
    // Error: CacheManager is not initialized
    @cache({ ttl: "10m" })
    async findById(id: number) { ... }
    
  2. Async Methods Only: Cannot be used with synchronous methods
    // ❌ Error
    @cache({ ttl: "10m" })
    findByIdSync(id: number) { ... }
    
    // ✅ OK
    @cache({ ttl: "10m" })
    async findById(id: number) { ... }
    
  3. Store Name Matching: The store option must match names defined in configuration
    // sonamu.config.ts
    stores: {
      myStore: store()...
    }
    
    // ❌ Error
    @cache({ store: "wrongName", ttl: "10m" })
    
    // ✅ OK
    @cache({ store: "myStore", ttl: "10m" })
    
  4. Serializable Values Only: Functions, Symbols, etc. cannot be cached
    // ❌ Bad example
    @cache({ ttl: "10m" })
    async getProcessor() {
      return {
        process: () => { ... }  // Functions cannot be serialized
      };
    }
    
    // ✅ Good example
    @cache({ ttl: "10m" })
    async getData() {
      return {
        id: 1,
        name: "test",
        values: [1, 2, 3]
      };
    }
    
  5. Argument Order Matters: Same values in different order result in different keys
    @cache({ ttl: "10m" })
    async find(name: string, age: number) { ... }
    
    find("John", 30)  // Key: "Model.find:["John",30]"
    find(30, "John")  // Key: "Model.find:[30,"John"]" (different key!)
    

Next Steps