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 { CommandHandler, EventBus, type ICommandHandler } from '@nestjs/cqrs';
import { Inject, BadRequestException } from '@nestjs/common';
import { createId } from '@paralleldrive/cuid2';
import { CacheService, CachePrefix } from '@modules/shared/infrastructure/cache.service';
import { CreateListingCommand } from './create-listing.command';
import { PROPERTY_REPOSITORY, type IPropertyRepository } from '../../../domain/repositories/property.repository';
import { LISTING_REPOSITORY, type IListingRepository } from '../../../domain/repositories/listing.repository';
@@ -22,6 +23,7 @@ export class CreateListingHandler implements ICommandHandler<CreateListingComman
@Inject(PROPERTY_REPOSITORY) private readonly propertyRepo: IPropertyRepository,
@Inject(LISTING_REPOSITORY) private readonly listingRepo: IListingRepository,
private readonly eventBus: EventBus,
private readonly cache: CacheService,
) {}
async execute(command: CreateListingCommand): Promise<CreateListingResult> {
@@ -87,6 +89,8 @@ export class CreateListingHandler implements ICommandHandler<CreateListingComman
this.eventBus.publish(event);
}
await this.cache.invalidateByPrefix(CachePrefix.SEARCH);
return {
listingId,
propertyId,

View File

@@ -1,6 +1,7 @@
import { CommandHandler, EventBus, type ICommandHandler } from '@nestjs/cqrs';
import { Inject } from '@nestjs/common';
import { NotFoundException } from '@modules/shared/domain/domain-exception';
import { CacheService, CachePrefix } from '@modules/shared/infrastructure/cache.service';
import { ModerateListingCommand } from './moderate-listing.command';
import { LISTING_REPOSITORY, type IListingRepository } from '../../../domain/repositories/listing.repository';
@@ -9,6 +10,7 @@ export class ModerateListingHandler implements ICommandHandler<ModerateListingCo
constructor(
@Inject(LISTING_REPOSITORY) private readonly listingRepo: IListingRepository,
private readonly eventBus: EventBus,
private readonly cache: CacheService,
) {}
async execute(command: ModerateListingCommand): Promise<{ status: string }> {
@@ -34,6 +36,11 @@ export class ModerateListingHandler implements ICommandHandler<ModerateListingCo
this.eventBus.publish(event);
}
await Promise.all([
this.cache.invalidate(CacheService.buildKey(CachePrefix.LISTING, command.listingId)),
this.cache.invalidateByPrefix(CachePrefix.SEARCH),
]);
return { status: listing.status };
}
}

View File

@@ -1,6 +1,7 @@
import { CommandHandler, EventBus, type ICommandHandler } from '@nestjs/cqrs';
import { Inject } from '@nestjs/common';
import { NotFoundException } from '@modules/shared/domain/domain-exception';
import { CacheService, CachePrefix } from '@modules/shared/infrastructure/cache.service';
import { UpdateListingStatusCommand } from './update-listing-status.command';
import { LISTING_REPOSITORY, type IListingRepository } from '../../../domain/repositories/listing.repository';
@@ -9,6 +10,7 @@ export class UpdateListingStatusHandler implements ICommandHandler<UpdateListing
constructor(
@Inject(LISTING_REPOSITORY) private readonly listingRepo: IListingRepository,
private readonly eventBus: EventBus,
private readonly cache: CacheService,
) {}
async execute(command: UpdateListingStatusCommand): Promise<{ status: string }> {
@@ -32,6 +34,11 @@ export class UpdateListingStatusHandler implements ICommandHandler<UpdateListing
this.eventBus.publish(event);
}
await Promise.all([
this.cache.invalidate(CacheService.buildKey(CachePrefix.LISTING, command.listingId)),
this.cache.invalidateByPrefix(CachePrefix.SEARCH),
]);
return { status: listing.status };
}
}

View File

@@ -1,6 +1,7 @@
import { QueryHandler, type IQueryHandler } from '@nestjs/cqrs';
import { Inject } from '@nestjs/common';
import { NotFoundException } from '@modules/shared/domain/domain-exception';
import { CacheService, CachePrefix, CacheTTL } from '@modules/shared/infrastructure/cache.service';
import { GetListingQuery } from './get-listing.query';
import { LISTING_REPOSITORY, type IListingRepository } from '../../../domain/repositories/listing.repository';
@@ -59,13 +60,23 @@ export interface ListingDetailDto {
export class GetListingHandler implements IQueryHandler<GetListingQuery> {
constructor(
@Inject(LISTING_REPOSITORY) private readonly listingRepo: IListingRepository,
private readonly cache: CacheService,
) {}
async execute(query: GetListingQuery): Promise<ListingDetailDto> {
const result = await this.listingRepo.findByIdWithProperty(query.listingId);
if (!result) {
throw new NotFoundException('Listing', query.listingId);
}
return result as unknown as ListingDetailDto;
const cacheKey = CacheService.buildKey(CachePrefix.LISTING, query.listingId);
return this.cache.getOrSet(
cacheKey,
async () => {
const result = await this.listingRepo.findByIdWithProperty(query.listingId);
if (!result) {
throw new NotFoundException('Listing', query.listingId);
}
return result as unknown as ListingDetailDto;
},
CacheTTL.LISTING_DETAIL,
'listing',
);
}
}