feat(cache): implement Redis caching layer for hot-read endpoints

Add cache-aside pattern for listing detail, search results, market
analytics (4 endpoints), and user profile queries. Cache invalidation
on all write mutations. Prometheus cache_hit_total/cache_miss_total
metrics with resource labels.

- CacheService: getOrSet, invalidate, invalidateByPrefix (SCAN-based)
- TTLs: listing 5m, search 1m, market 30m, profile 10m
- All 230 tests passing (13 new cache tests + 6 updated handler tests)

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
Ho Ngoc Hai
2026-04-08 04:14:06 +07:00
parent 09034a5f9b
commit 2a392525a2
23 changed files with 472 additions and 60 deletions

View File

@@ -1,6 +1,7 @@
import { GetDistrictStatsHandler } from '../queries/get-district-stats/get-district-stats.handler';
import { GetDistrictStatsQuery } from '../queries/get-district-stats/get-district-stats.query';
import { type IMarketIndexRepository, type DistrictStatsResult } from '../../domain/repositories/market-index.repository';
import { type CacheService } from '@modules/shared/infrastructure/cache.service';
describe('GetDistrictStatsHandler', () => {
let handler: GetDistrictStatsHandler;
@@ -17,7 +18,8 @@ describe('GetDistrictStatsHandler', () => {
getPriceTrend: vi.fn(),
getDistrictStats: vi.fn(),
};
handler = new GetDistrictStatsHandler(mockRepo as any);
const mockCache = { getOrSet: vi.fn((_key: string, loader: () => Promise<unknown>) => loader()) } as unknown as CacheService;
handler = new GetDistrictStatsHandler(mockRepo as any, mockCache);
});
it('returns district statistics', async () => {

View File

@@ -1,6 +1,7 @@
import { GetHeatmapHandler } from '../queries/get-heatmap/get-heatmap.handler';
import { GetHeatmapQuery } from '../queries/get-heatmap/get-heatmap.query';
import { type IMarketIndexRepository, type HeatmapDataPoint } from '../../domain/repositories/market-index.repository';
import { type CacheService } from '@modules/shared/infrastructure/cache.service';
describe('GetHeatmapHandler', () => {
let handler: GetHeatmapHandler;
@@ -17,7 +18,8 @@ describe('GetHeatmapHandler', () => {
getPriceTrend: vi.fn(),
getDistrictStats: vi.fn(),
};
handler = new GetHeatmapHandler(mockRepo as any);
const mockCache = { getOrSet: vi.fn((_key: string, loader: () => Promise<unknown>) => loader()) } as unknown as CacheService;
handler = new GetHeatmapHandler(mockRepo as any, mockCache);
});
it('returns heatmap data for a city and period', async () => {

View File

@@ -1,6 +1,7 @@
import { GetMarketReportHandler } from '../queries/get-market-report/get-market-report.handler';
import { GetMarketReportQuery } from '../queries/get-market-report/get-market-report.query';
import { type IMarketIndexRepository, type MarketReportResult } from '../../domain/repositories/market-index.repository';
import { type CacheService } from '@modules/shared/infrastructure/cache.service';
describe('GetMarketReportHandler', () => {
let handler: GetMarketReportHandler;
@@ -17,7 +18,8 @@ describe('GetMarketReportHandler', () => {
getPriceTrend: vi.fn(),
getDistrictStats: vi.fn(),
};
handler = new GetMarketReportHandler(mockRepo as any);
const mockCache = { getOrSet: vi.fn((_key: string, loader: () => Promise<unknown>) => loader()) } as unknown as CacheService;
handler = new GetMarketReportHandler(mockRepo as any, mockCache);
});
it('returns market report with district data', async () => {

View File

@@ -1,6 +1,7 @@
import { GetPriceTrendHandler } from '../queries/get-price-trend/get-price-trend.handler';
import { GetPriceTrendQuery } from '../queries/get-price-trend/get-price-trend.query';
import { type IMarketIndexRepository, type PriceTrendPoint } from '../../domain/repositories/market-index.repository';
import { type CacheService } from '@modules/shared/infrastructure/cache.service';
describe('GetPriceTrendHandler', () => {
let handler: GetPriceTrendHandler;
@@ -17,7 +18,8 @@ describe('GetPriceTrendHandler', () => {
getPriceTrend: vi.fn(),
getDistrictStats: vi.fn(),
};
handler = new GetPriceTrendHandler(mockRepo as any);
const mockCache = { getOrSet: vi.fn((_key: string, loader: () => Promise<unknown>) => loader()) } as unknown as CacheService;
handler = new GetPriceTrendHandler(mockRepo as any, mockCache);
});
it('returns price trend data for a district', async () => {

View File

@@ -2,6 +2,7 @@ import { UpdateMarketIndexHandler } from '../commands/update-market-index/update
import { UpdateMarketIndexCommand } from '../commands/update-market-index/update-market-index.command';
import { type IMarketIndexRepository } from '../../domain/repositories/market-index.repository';
import { MarketIndexEntity } from '../../domain/entities/market-index.entity';
import { type CacheService } from '@modules/shared/infrastructure/cache.service';
function createExistingEntity(): MarketIndexEntity {
return new MarketIndexEntity('idx-1', {
@@ -34,7 +35,8 @@ describe('UpdateMarketIndexHandler', () => {
getPriceTrend: vi.fn(),
getDistrictStats: vi.fn(),
};
handler = new UpdateMarketIndexHandler(mockRepo as any);
const mockCache = { invalidateByPrefix: vi.fn() } as unknown as CacheService;
handler = new UpdateMarketIndexHandler(mockRepo as any, mockCache);
});
it('creates a new market index when none exists', async () => {

View File

@@ -1,5 +1,6 @@
import { CommandHandler, type ICommandHandler } from '@nestjs/cqrs';
import { Inject } from '@nestjs/common';
import { CacheService, CachePrefix } from '@modules/shared/infrastructure/cache.service';
import { UpdateMarketIndexCommand } from './update-market-index.command';
import {
MARKET_INDEX_REPOSITORY,
@@ -16,6 +17,7 @@ export interface UpdateMarketIndexResult {
export class UpdateMarketIndexHandler implements ICommandHandler<UpdateMarketIndexCommand> {
constructor(
@Inject(MARKET_INDEX_REPOSITORY) private readonly marketIndexRepo: IMarketIndexRepository,
private readonly cache: CacheService,
) {}
async execute(command: UpdateMarketIndexCommand): Promise<UpdateMarketIndexResult> {
@@ -37,6 +39,7 @@ export class UpdateMarketIndexHandler implements ICommandHandler<UpdateMarketInd
command.yoyChange,
);
await this.marketIndexRepo.update(existing);
await this.invalidateMarketCaches();
return { id: existing.id, created: false };
}
@@ -57,6 +60,18 @@ export class UpdateMarketIndexHandler implements ICommandHandler<UpdateMarketInd
);
await this.marketIndexRepo.save(entity);
await this.invalidateMarketCaches();
return { id, created: true };
}
private async invalidateMarketCaches(): Promise<void> {
await Promise.all([
this.cache.invalidateByPrefix(CachePrefix.MARKET_REPORT),
this.cache.invalidateByPrefix(CachePrefix.MARKET_TREND),
this.cache.invalidateByPrefix(CachePrefix.MARKET_HEATMAP),
this.cache.invalidateByPrefix(CachePrefix.MARKET_DISTRICT),
]);
}
}

View File

@@ -1,5 +1,6 @@
import { QueryHandler, type IQueryHandler } from '@nestjs/cqrs';
import { Inject } from '@nestjs/common';
import { CacheService, CachePrefix, CacheTTL } from '@modules/shared/infrastructure/cache.service';
import { GetDistrictStatsQuery } from './get-district-stats.query';
import {
MARKET_INDEX_REPOSITORY,
@@ -17,15 +18,20 @@ export interface DistrictStatsDto {
export class GetDistrictStatsHandler implements IQueryHandler<GetDistrictStatsQuery> {
constructor(
@Inject(MARKET_INDEX_REPOSITORY) private readonly marketIndexRepo: IMarketIndexRepository,
private readonly cache: CacheService,
) {}
async execute(query: GetDistrictStatsQuery): Promise<DistrictStatsDto> {
const districts = await this.marketIndexRepo.getDistrictStats(query.city, query.period);
const cacheKey = CacheService.buildKey(CachePrefix.MARKET_DISTRICT, query.city, query.period);
return {
city: query.city,
period: query.period,
districts,
};
return this.cache.getOrSet(
cacheKey,
async () => {
const districts = await this.marketIndexRepo.getDistrictStats(query.city, query.period);
return { city: query.city, period: query.period, districts };
},
CacheTTL.MARKET_DATA,
'district_stats',
);
}
}

View File

@@ -1,5 +1,6 @@
import { QueryHandler, type IQueryHandler } from '@nestjs/cqrs';
import { Inject } from '@nestjs/common';
import { CacheService, CachePrefix, CacheTTL } from '@modules/shared/infrastructure/cache.service';
import { GetHeatmapQuery } from './get-heatmap.query';
import {
MARKET_INDEX_REPOSITORY,
@@ -17,15 +18,20 @@ export interface HeatmapDto {
export class GetHeatmapHandler implements IQueryHandler<GetHeatmapQuery> {
constructor(
@Inject(MARKET_INDEX_REPOSITORY) private readonly marketIndexRepo: IMarketIndexRepository,
private readonly cache: CacheService,
) {}
async execute(query: GetHeatmapQuery): Promise<HeatmapDto> {
const dataPoints = await this.marketIndexRepo.getHeatmap(query.city, query.period);
const cacheKey = CacheService.buildKey(CachePrefix.MARKET_HEATMAP, query.city, query.period);
return {
city: query.city,
period: query.period,
dataPoints,
};
return this.cache.getOrSet(
cacheKey,
async () => {
const dataPoints = await this.marketIndexRepo.getHeatmap(query.city, query.period);
return { city: query.city, period: query.period, dataPoints };
},
CacheTTL.MARKET_DATA,
'heatmap',
);
}
}

View File

@@ -1,5 +1,6 @@
import { QueryHandler, type IQueryHandler } from '@nestjs/cqrs';
import { Inject } from '@nestjs/common';
import { CacheService, CachePrefix, CacheTTL } from '@modules/shared/infrastructure/cache.service';
import { GetMarketReportQuery } from './get-market-report.query';
import {
MARKET_INDEX_REPOSITORY,
@@ -17,19 +18,24 @@ export interface MarketReportDto {
export class GetMarketReportHandler implements IQueryHandler<GetMarketReportQuery> {
constructor(
@Inject(MARKET_INDEX_REPOSITORY) private readonly marketIndexRepo: IMarketIndexRepository,
private readonly cache: CacheService,
) {}
async execute(query: GetMarketReportQuery): Promise<MarketReportDto> {
const districts = await this.marketIndexRepo.getMarketReport(
query.city,
query.period,
query.propertyType,
);
const cacheKey = CacheService.buildKey(CachePrefix.MARKET_REPORT, query.city, query.period, query.propertyType);
return {
city: query.city,
period: query.period,
districts,
};
return this.cache.getOrSet(
cacheKey,
async () => {
const districts = await this.marketIndexRepo.getMarketReport(
query.city,
query.period,
query.propertyType,
);
return { city: query.city, period: query.period, districts };
},
CacheTTL.MARKET_DATA,
'market_report',
);
}
}

View File

@@ -1,5 +1,6 @@
import { QueryHandler, type IQueryHandler } from '@nestjs/cqrs';
import { Inject } from '@nestjs/common';
import { CacheService, CachePrefix, CacheTTL } from '@modules/shared/infrastructure/cache.service';
import { GetPriceTrendQuery } from './get-price-trend.query';
import {
MARKET_INDEX_REPOSITORY,
@@ -18,21 +19,31 @@ export interface PriceTrendDto {
export class GetPriceTrendHandler implements IQueryHandler<GetPriceTrendQuery> {
constructor(
@Inject(MARKET_INDEX_REPOSITORY) private readonly marketIndexRepo: IMarketIndexRepository,
private readonly cache: CacheService,
) {}
async execute(query: GetPriceTrendQuery): Promise<PriceTrendDto> {
const trend = await this.marketIndexRepo.getPriceTrend(
const cacheKey = CacheService.buildKey(
CachePrefix.MARKET_TREND,
query.district,
query.city,
query.propertyType,
query.periods,
query.periods?.join(','),
);
return {
district: query.district,
city: query.city,
propertyType: query.propertyType,
trend,
};
return this.cache.getOrSet(
cacheKey,
async () => {
const trend = await this.marketIndexRepo.getPriceTrend(
query.district,
query.city,
query.propertyType,
query.periods,
);
return { district: query.district, city: query.city, propertyType: query.propertyType, trend };
},
CacheTTL.MARKET_DATA,
'price_trend',
);
}
}