feat(api): add @Cacheable decorator and plan list caching

- Create @Cacheable method decorator for declarative cache-aside pattern
  with configurable prefix, TTL, resource label, and key extraction
- Add PLAN_LIST (1h TTL) and REFERENCE_DATA (24h TTL) cache constants
- Add CachePrefix.PLAN_LIST and CachePrefix.REFERENCE entries
- Cache subscription plan queries in GetPlanHandler (single + list)
- Export Cacheable decorator from shared module barrel
- Add comprehensive tests for decorator and handler caching

The caching infrastructure (CacheService, Redis, Prometheus metrics,
event-driven invalidation) was already production-ready with 10+ hot
paths cached. This commit adds the missing declarative decorator and
plan list caching.

Resolves: TEC-1567

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
Ho Ngoc Hai
2026-04-09 10:26:59 +07:00
parent 862078df37
commit 2611cfa867
6 changed files with 222 additions and 19 deletions

View File

@@ -0,0 +1,121 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { CachePrefix, CacheService, CacheTTL } from '../cache.service';
import { Cacheable } from '../decorators/cacheable.decorator';
describe('Cacheable decorator', () => {
let mockCacheService: { getOrSet: ReturnType<typeof vi.fn> };
beforeEach(() => {
mockCacheService = {
getOrSet: vi.fn(),
};
});
it('should call cacheService.getOrSet with correct key and TTL', async () => {
class TestHandler {
cacheService = mockCacheService;
@Cacheable({
prefix: CachePrefix.LISTING,
ttl: CacheTTL.LISTING_DETAIL,
resource: 'listing',
keyFrom: (query: unknown) => [(query as { id: string }).id],
})
async execute(query: { id: string }): Promise<string> {
return `listing-${query.id}`;
}
}
mockCacheService.getOrSet.mockResolvedValue('cached-result');
const handler = new TestHandler();
const result = await handler.execute({ id: 'abc123' });
expect(result).toBe('cached-result');
expect(mockCacheService.getOrSet).toHaveBeenCalledWith(
'cache:listing:abc123',
expect.any(Function),
300,
'listing',
);
});
it('should use first argument as default key when keyFrom is not provided', async () => {
class TestHandler {
cacheService = mockCacheService;
@Cacheable({
prefix: CachePrefix.PLAN_LIST,
ttl: CacheTTL.PLAN_LIST,
resource: 'plan',
})
async execute(query: string): Promise<string> {
return `plan-${query}`;
}
}
mockCacheService.getOrSet.mockResolvedValue('cached-plan');
const handler = new TestHandler();
await handler.execute('PREMIUM');
expect(mockCacheService.getOrSet).toHaveBeenCalledWith(
'cache:plan:list:premium',
expect.any(Function),
3600,
'plan',
);
});
it('should execute the original method as the loader function', async () => {
class TestHandler {
cacheService = mockCacheService;
@Cacheable({
prefix: CachePrefix.LISTING,
ttl: CacheTTL.LISTING_DETAIL,
resource: 'listing',
keyFrom: (query: unknown) => [(query as { id: string }).id],
})
async execute(_query: { id: string }): Promise<string> {
return 'original-result';
}
}
mockCacheService.getOrSet.mockImplementation(
async (_key: string, loader: () => Promise<string>) => loader(),
);
const handler = new TestHandler();
const result = await handler.execute({ id: 'xyz' });
expect(result).toBe('original-result');
});
it('should handle multiple key parts from keyFrom', async () => {
class TestHandler {
cacheService = mockCacheService;
@Cacheable({
prefix: CachePrefix.SEARCH,
ttl: CacheTTL.SEARCH_RESULTS,
resource: 'search',
keyFrom: (query: unknown) => {
const q = query as { district: string; type: string };
return [q.district, q.type];
},
})
async execute(_query: { district: string; type: string }): Promise<unknown[]> {
return [];
}
}
mockCacheService.getOrSet.mockResolvedValue([]);
const handler = new TestHandler();
await handler.execute({ district: 'Quận 1', type: 'APARTMENT' });
expect(mockCacheService.getOrSet).toHaveBeenCalledWith(
'cache:search:quận_1:apartment',
expect.any(Function),
120,
'search',
);
});
});

View File

@@ -24,6 +24,10 @@ export const CacheTTL = {
USER_PROFILE: 600, // 10 min
/** User quota — very short TTL, invalidated on usage metering and plan changes */
USER_QUOTA: 60, // 1 min
/** Subscription plan list — long TTL, rarely changes */
PLAN_LIST: 3600, // 1 hour
/** Reference data (districts, wards) — very long TTL, static data */
REFERENCE_DATA: 86400, // 24 hours
} as const;
export enum CachePrefix {
@@ -37,6 +41,8 @@ export enum CachePrefix {
USER_PROFILE = 'cache:user:profile',
USER_QUOTA = 'cache:user:quota',
VALUATION = 'cache:valuation',
PLAN_LIST = 'cache:plan:list',
REFERENCE = 'cache:reference',
}
@Injectable()

View File

@@ -0,0 +1,56 @@
import { type CachePrefix, CacheService, type CacheTTL } from '../cache.service';
/**
* Metadata key for @Cacheable decorator options.
*/
export const CACHEABLE_METADATA = Symbol('CACHEABLE_METADATA');
export interface CacheableOptions {
/** Cache key prefix — use CachePrefix enum values. */
prefix: CachePrefix;
/** TTL in seconds — use CacheTTL constants. */
ttl: (typeof CacheTTL)[keyof typeof CacheTTL];
/** Resource label for Prometheus metrics. */
resource: string;
/**
* Extract cache key parts from method arguments.
* Return an array of strings that will be joined with the prefix.
* Defaults to using the first argument's string representation.
*/
keyFrom?: (...args: unknown[]) => (string | number | undefined)[];
}
/**
* Declarative cache-aside decorator for query handler `execute()` methods.
*
* Usage:
* ```ts
* @Cacheable({ prefix: CachePrefix.LISTING, ttl: CacheTTL.LISTING_DETAIL, resource: 'listing' })
* async execute(query: GetListingQuery): Promise<ListingDto> { ... }
* ```
*
* Requires the class to have a `cacheService: CacheService` property
* (injected via constructor).
*/
export function Cacheable(options: CacheableOptions): MethodDecorator {
return function (_target: object, _propertyKey: string | symbol, descriptor: PropertyDescriptor) {
const originalMethod = descriptor.value as (...args: unknown[]) => Promise<unknown>;
descriptor.value = async function (this: { cacheService: CacheService }, ...args: unknown[]) {
const keyParts = options.keyFrom
? options.keyFrom(...args)
: [String(args[0] ?? 'default')];
const cacheKey = CacheService.buildKey(options.prefix, ...keyParts);
return this.cacheService.getOrSet(
cacheKey,
() => originalMethod.apply(this, args),
options.ttl,
options.resource,
);
};
return descriptor;
};
}

View File

@@ -1,3 +1,4 @@
export { Cacheable, type CacheableOptions } from './decorators/cacheable.decorator';
export { PrismaService } from './prisma.service';
export { RedisService } from './redis.service';
export { CacheService, CachePrefix, CacheTTL } from './cache.service';

View File

@@ -17,6 +17,8 @@ describe('GetPlanHandler', () => {
isActive: true,
};
let mockCacheService: any;
beforeEach(() => {
mockPrisma = {
plan: {
@@ -25,7 +27,11 @@ describe('GetPlanHandler', () => {
},
};
handler = new GetPlanHandler(mockPrisma);
mockCacheService = {
getOrSet: vi.fn((_key: string, loader: () => Promise<any>) => loader()),
};
handler = new GetPlanHandler(mockPrisma, mockCacheService);
});
it('returns a single plan by tier', async () => {

View File

@@ -1,6 +1,6 @@
import { type IQueryHandler, QueryHandler } from '@nestjs/cqrs';
import { type Plan } from '@prisma/client';
import { NotFoundException, type PrismaService } from '@modules/shared';
import { CacheService, CachePrefix, CacheTTL, NotFoundException, type PrismaService } from '@modules/shared';
import { GetPlanQuery } from './get-plan.query';
export interface PlanDto {
@@ -17,28 +17,41 @@ export interface PlanDto {
@QueryHandler(GetPlanQuery)
export class GetPlanHandler implements IQueryHandler<GetPlanQuery> {
constructor(private readonly prisma: PrismaService) {}
constructor(
private readonly prisma: PrismaService,
private readonly cacheService: CacheService,
) {}
async execute(query: GetPlanQuery): Promise<PlanDto | PlanDto[]> {
if (query.planTier) {
let plan;
try {
plan = await this.prisma.plan.findFirst({
where: { tier: query.planTier, isActive: true },
});
} catch {
throw new NotFoundException('Plan', query.planTier);
}
if (!plan) throw new NotFoundException('Plan', query.planTier);
return this.toDto(plan);
const cacheKey = CacheService.buildKey(CachePrefix.PLAN_LIST, query.planTier);
return this.cacheService.getOrSet(
cacheKey,
async () => {
const plan = await this.prisma.plan.findFirst({
where: { tier: query.planTier, isActive: true },
});
if (!plan) throw new NotFoundException('Plan', query.planTier);
return this.toDto(plan);
},
CacheTTL.PLAN_LIST,
'plan',
);
}
const plans = await this.prisma.plan.findMany({
where: { isActive: true },
orderBy: { priceMonthlyVND: 'asc' },
});
return plans.map((p) => this.toDto(p));
const cacheKey = CacheService.buildKey(CachePrefix.PLAN_LIST, 'all');
return this.cacheService.getOrSet(
cacheKey,
async () => {
const plans = await this.prisma.plan.findMany({
where: { isActive: true },
orderBy: { priceMonthlyVND: 'asc' },
});
return plans.map((p) => this.toDto(p));
},
CacheTTL.PLAN_LIST,
'plan',
);
}
private toDto(plan: Plan): PlanDto {