Skip to content
26 changes: 25 additions & 1 deletion src/cache-kit.module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,8 @@ import {
type InjectionToken,
Module,
type ModuleMetadata,
OnModuleInit,
Optional,
type OptionalFactoryDependency,
Provider,
Type,
Expand All @@ -32,6 +34,7 @@ import type { ICacheStore } from "@ports/cache-store.port";

import { CACHE_MODULE_OPTIONS, CACHE_STORE } from "./constants";
import { CacheService } from "./services/cache.service";
import { CacheServiceRef } from "./utils/cache-service-ref";

// ---------------------------------------------------------------------------
// Configuration interfaces
Expand Down Expand Up @@ -210,7 +213,28 @@ function createAsyncProviders(options: CacheModuleAsyncOptions): Provider[] {
* ```
*/
@Module({})
export class CacheModule {
export class CacheModule implements OnModuleInit {
constructor(
/**
* Injected by NestJS from the providers registered in register() / registerAsync().
* @Optional() guards against the rare case where the module class is instantiated
* without CacheService being available (e.g. partial test setups).
*/
@Optional() private readonly cacheService?: CacheService,
) {}

/**
* Runs after all providers in this module have been resolved.
* Stores the CacheService reference so @Cacheable and @CacheEvict can access
* it at method-call time without going through constructor injection.
*/
onModuleInit(): void {
// Only populate the ref when CacheService is available
if (this.cacheService) {
CacheServiceRef.set(this.cacheService);
}
}

/**
* Register the module with synchronous, inline configuration.
*
Expand Down
76 changes: 76 additions & 0 deletions src/decorators/cache-evict.decorator.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
/**
* @file cache-evict.decorator.ts
*
* @CacheEvict method decorator — removes a cache entry after the method executes.
*
* When applied to a method, it:
* 1. Calls the original method and awaits its result.
* 2. Resolves the cache key by interpolating method arguments into the template.
* 3. Deletes the matching cache entry so the next read fetches fresh data.
*
* The eviction happens AFTER the method succeeds — if the method throws, the
* cache entry is left intact.
*
* Works with both sync and async methods. The wrapped method always returns a
* Promise because the cache delete operation is asynchronous.
*
* CacheService is resolved from the singleton CacheServiceRef which is populated
* by CacheModule.onModuleInit() — no extra injection is required in consumer classes.
*
* Exports:
* - CacheEvict → method decorator factory
*/

import { CacheServiceRef } from "@utils/cache-service-ref";
import { resolveCacheKey } from "@utils/resolve-cache-key.util";

/**
* Cache eviction method decorator.
*
* @param key - Cache key template to delete after the method executes.
* Use `{0}`, `{1}`, … to interpolate method arguments,
* e.g. `"user:{0}"`.
*
* @example Static key eviction
* ```typescript
* @CacheEvict("all-products")
* async createProduct(dto: CreateProductDto): Promise<Product> { ... }
* ```
*
* @example Dynamic key with argument interpolation
* ```typescript
* @CacheEvict("user:{0}")
* async updateUser(id: string, dto: UpdateUserDto): Promise<User> { ... }
* ```
*/
export function CacheEvict(key: string): MethodDecorator {
return (
_target: object,
_propertyKey: string | symbol,
descriptor: PropertyDescriptor,
): PropertyDescriptor => {
// Capture the original method before replacing it
const originalMethod = descriptor.value as (...args: unknown[]) => unknown;

// Replace the method with a cache-evicting wrapper
descriptor.value = async function (this: unknown, ...args: unknown[]): Promise<unknown> {
// Resolve the CacheService from the module-level singleton
const cacheService = CacheServiceRef.get();

// ── Execute the original method first ──────────────────────────────
// Wrap in Promise.resolve() to support both sync and async methods.
// If the method throws, the error propagates and eviction is skipped
// so we don't invalidate a cache entry for a failed operation.
const result = await Promise.resolve(originalMethod.apply(this, args));

// ── Evict cache entry after successful execution ───────────────────
// Interpolate {n} placeholders using the actual call arguments
const resolvedKey = resolveCacheKey(key, args);
await cacheService.delete(resolvedKey);

return result;
};

return descriptor;
};
}
85 changes: 85 additions & 0 deletions src/decorators/cacheable.decorator.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
/**
* @file cacheable.decorator.ts
*
* @Cacheable method decorator — implements the cache-aside pattern automatically.
*
* When applied to a method, it:
* 1. Resolves the cache key by interpolating method arguments into the template.
* 2. Returns the cached value immediately if it exists (cache hit).
* 3. On a cache miss, calls the original method, stores the result, and returns it.
*
* Works with both sync and async methods. The wrapped method always returns a Promise
* because cache read/write operations are inherently asynchronous.
*
* CacheService is resolved from the singleton CacheServiceRef which is populated
* by CacheModule.onModuleInit() — no extra injection is required in consumer classes.
*
* Exports:
* - Cacheable → method decorator factory
*/

import { CacheServiceRef } from "@utils/cache-service-ref";
import { resolveCacheKey } from "@utils/resolve-cache-key.util";

/**
* Cache-aside method decorator.
*
* @param key - Cache key template. Use `{0}`, `{1}`, … to interpolate
* method arguments, e.g. `"user:{0}"`.
* @param ttlSeconds - Optional TTL in seconds. Falls back to the module-level
* default TTL configured in CacheModule.register().
*
* @example Static key
* ```typescript
* @Cacheable("all-products", 300)
* async findAllProducts(): Promise<Product[]> { ... }
* ```
*
* @example Dynamic key with argument interpolation
* ```typescript
* @Cacheable("user:{0}", 60)
* async findUserById(id: string): Promise<User> { ... }
* ```
*
* @example Without explicit TTL (inherits module default)
* ```typescript
* @Cacheable("config")
* async getConfig(): Promise<Config> { ... }
* ```
*/
export function Cacheable(key: string, ttlSeconds?: number): MethodDecorator {
return (
_target: object,
_propertyKey: string | symbol,
descriptor: PropertyDescriptor,
): PropertyDescriptor => {
// Capture the original method before replacing it
const originalMethod = descriptor.value as (...args: unknown[]) => unknown;

// Replace the method with a cache-aware wrapper
descriptor.value = async function (this: unknown, ...args: unknown[]): Promise<unknown> {
// Resolve the CacheService from the module-level singleton
const cacheService = CacheServiceRef.get();

// Interpolate any {n} placeholders in the key template with the actual args
const resolvedKey = resolveCacheKey(key, args);

// ── Cache hit ──────────────────────────────────────────────────────
// Return the stored value immediately without calling the original method
const cached = await cacheService.get<unknown>(resolvedKey);
if (cached !== null) return cached;

// ── Cache miss ─────────────────────────────────────────────────────
// Call the original method; wrap in Promise.resolve() to handle both
// sync methods (returns a plain value) and async methods (returns a Promise)
const result = await Promise.resolve(originalMethod.apply(this, args));

// Persist the result under the resolved key for future calls
await cacheService.set(resolvedKey, result, ttlSeconds);
Comment on lines +77 to +78
Copy link

Copilot AI Apr 3, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cacheable will attempt to cache whatever the wrapped method returns. If the method returns undefined (common for void/command-style methods), JSON.stringify(undefined) yields undefined, which is not valid JSON and will cause reads to always miss (or potentially fail depending on the store implementation). Consider skipping cacheService.set() when the result is undefined, or coercing/handling it explicitly.

Suggested change
// Persist the result under the resolved key for future calls
await cacheService.set(resolvedKey, result, ttlSeconds);
// Persist the result under the resolved key for future calls.
// Skip caching undefined because some cache stores serialize values as JSON,
// and JSON.stringify(undefined) does not produce a valid cache payload.
if (result !== undefined) {
await cacheService.set(resolvedKey, result, ttlSeconds);
}

Copilot uses AI. Check for mistakes.

return result;
};

return descriptor;
};
}
11 changes: 11 additions & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,17 @@ export { ExampleGuard } from "./guards/example.guard";
// Export decorators for use in consumer controllers/services
export { ExampleData, ExampleParam } from "./decorators/example.decorator";

// ============================================================================
// DECORATORS
// ============================================================================
// Method decorators for automatic caching and cache invalidation.
// Apply these to service methods — no manual CacheService injection needed.

// Cache-aside decorator: returns cached value or calls the method and stores the result
export { Cacheable } from "./decorators/cacheable.decorator";
// Cache eviction decorator: deletes the cache entry after the method executes
export { CacheEvict } from "./decorators/cache-evict.decorator";
Comment on lines +55 to +61
Copy link

Copilot AI Apr 3, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This PR adds new public exports (Cacheable, CacheEvict) from the package entrypoint. Since this changes the consumer-facing API, add a changeset (npx changeset) so the version/changelog is updated consistently with the repo’s release workflow.

Copilot uses AI. Check for mistakes.

// ============================================================================
// TYPES & INTERFACES (For TypeScript Typing)
// ============================================================================
Expand Down
72 changes: 72 additions & 0 deletions src/utils/cache-service-ref.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
/**
* @file cache-service-ref.ts
*
* Module-scoped singleton reference to the active CacheService instance.
*
* Why this exists:
* Method decorators (@Cacheable, @CacheEvict) are applied at class-definition
* time, long before NestJS has assembled the DI container. They cannot receive
* CacheService via constructor injection. Instead, CacheModule stores a reference
* here during `onModuleInit()`, and the decorators read it at call time.
*
* Lifecycle:
* 1. App bootstraps → NestJS initialises CacheModule.
* 2. CacheModule.onModuleInit() calls CacheServiceRef.set(cacheService).
* 3. First method call with @Cacheable / @CacheEvict → CacheServiceRef.get() succeeds.
*
* Exports:
* - CacheServiceRef → { set, get } singleton accessor
*/

import type { CacheService } from "@services/cache.service";

// ---------------------------------------------------------------------------
// Internal holder — private to this module
// ---------------------------------------------------------------------------

/**
* The single shared CacheService instance.
* null until CacheModule.onModuleInit() runs.
*/
let _instance: CacheService | null = null;

// ---------------------------------------------------------------------------
// Public accessor
// ---------------------------------------------------------------------------

/**
* Singleton accessor for the active CacheService.
*
* Populated by CacheModule during application bootstrap.
* Used internally by @Cacheable and @CacheEvict at method-call time.
*/
export const CacheServiceRef = {
/**
* Store the CacheService instance.
* Called once by CacheModule.onModuleInit().
*
* @param service - The resolved CacheService from NestJS DI
*/
set(service: CacheService): void {
// Overwrite any prior value — safe for hot-reload scenarios
_instance = service;
Comment on lines +46 to +52
Copy link

Copilot AI Apr 3, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

CacheServiceRef.set() unconditionally overwrites the previously stored instance. If CacheModule.register() is imported more than once in the same process (e.g., different feature modules with different cache configs), whichever module initializes last will silently take over and all decorators will use the wrong CacheService. Consider preventing re-initialization (throw if already set with a different instance) or otherwise scoping the reference so multiple module instances can coexist safely.

Suggested change
* Called once by CacheModule.onModuleInit().
*
* @param service - The resolved CacheService from NestJS DI
*/
set(service: CacheService): void {
// Overwrite any prior value — safe for hot-reload scenarios
_instance = service;
* Called by CacheModule.onModuleInit().
*
* Re-initialisation is allowed only when the same instance is provided again
* (for example, during an idempotent bootstrap path). A different instance is
* rejected because decorators resolve this module-scoped reference globally,
* and silently replacing it would make them use the wrong CacheService.
*
* @param service - The resolved CacheService from NestJS DI
* @throws {Error} If a different CacheService instance was already registered
*/
set(service: CacheService): void {
if (_instance === null) {
_instance = service;
return;
}
if (_instance !== service) {
throw new Error(
"[CacheKit] CacheServiceRef has already been initialised with a different instance. " +
"Multiple CacheModule.register() or CacheModule.registerAsync() imports in the same " +
"process are not supported by the global decorator cache reference.",
);
}

Copilot uses AI. Check for mistakes.
},

/**
* Retrieve the stored CacheService.
* Throws a descriptive error if called before the module has initialised.
*
* @returns The active CacheService instance
* @throws {Error} If CacheModule was not imported in the application
*/
get(): CacheService {
if (_instance === null) {
throw new Error(
"[CacheKit] CacheService is not initialised. " +
"Make sure CacheModule.register() or CacheModule.registerAsync() " +
"is imported in your root AppModule before using @Cacheable or @CacheEvict.",
);
}
return _instance;
},
};
40 changes: 40 additions & 0 deletions src/utils/resolve-cache-key.util.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
/**
* @file resolve-cache-key.util.ts
*
* Utility for resolving cache key templates at runtime.
*
* Templates support positional argument interpolation using the `{n}` syntax,
* where `n` is the zero-based index of the method argument.
*
* Exports:
* - resolveCacheKey → replaces `{0}`, `{1}`, … in a template with actual argument values
*/

/**
* Resolve a cache key template by substituting `{n}` placeholders with the
* corresponding method argument values.
*
* Rules:
* - `{0}` is replaced with `String(args[0])`
* - `{1}` is replaced with `String(args[1])`, and so on
* - Placeholders that reference a missing argument are replaced with an empty string
*
* @param template - Key template, e.g. `"user:{0}"` or `"post:{0}:comment:{1}"`
* @param args - The method arguments passed at call time
* @returns Fully resolved cache key string
*
* @example
* resolveCacheKey("user:{0}", ["42"]) // → "user:42"
* resolveCacheKey("post:{0}:comments", [7]) // → "post:7:comments"
* resolveCacheKey("static-key", []) // → "static-key"
*/
export function resolveCacheKey(template: string, args: unknown[]): string {
// Replace every {n} token with the stringified value of args[n].
// The regex matches literal braces wrapping one or more digits.
return template.replace(/\{(\d+)\}/g, (_match, indexStr: string) => {

Check warning on line 34 in src/utils/resolve-cache-key.util.ts

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Prefer `String#replaceAll()` over `String#replace()`.

See more on https://sonarcloud.io/project/issues?id=CISCODE-MA_CacheKit&issues=AZ1TDoY6f2jVVBs5svCI&open=AZ1TDoY6f2jVVBs5svCI&pullRequest=3
const value = args[Number(indexStr)];

// If the argument exists, coerce it to a string; otherwise leave empty
return value !== undefined && value !== null ? String(value) : "";

Check warning on line 38 in src/utils/resolve-cache-key.util.ts

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

'value' will use Object's default stringification format ('[object Object]') when stringified.

See more on https://sonarcloud.io/project/issues?id=CISCODE-MA_CacheKit&issues=AZ1TDoY6f2jVVBs5svCJ&open=AZ1TDoY6f2jVVBs5svCJ&pullRequest=3
});
}
3 changes: 2 additions & 1 deletion tsconfig.json
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,8 @@
"@middleware/*": ["src/middleware/*"],
"@utils/*": ["src/utils/*"],
"@ports/*": ["src/ports/*"],
"@adapters/*": ["src/adapters/*"]
"@adapters/*": ["src/adapters/*"],
"@utils/*": ["src/utils/*"]
}
},
"include": ["src/**/*.ts", "test/**/*.ts"],
Expand Down
Loading