Cache and outbound HTTP
This recipe caches tenant-scoped catalog responses and propagates tenant identity only to one exact internal origin.
Merge this focused configuration with the verified JWT or trusted-gateway identity setup from Identity and permissions. Without a trusted tenant in request context, tenant-scoped handlers deliberately bypass the cache and no identity headers propagate.
Module setup
import { Injectable, Module } from '@nestjs/common';
import { APP_INTERCEPTOR } from '@nestjs/core';
import { SdCoreModule } from '@sdcorejs/nestjs';
import {
CacheInterceptor,
HttpService,
} from '@sdcorejs/nestjs/services';
@Injectable()
class InventoryClient {
constructor(private readonly http: HttpService) {}
availability(productId: string) {
return this.http.get<{ available: boolean }>(
'/availability/' + encodeURIComponent(productId),
);
}
}
@Module({
imports: [
SdCoreModule.forRoot({
cache: {
backend: 'redis',
ttl: 60,
fallbackToMemory: false,
redis: {
host: process.env.REDIS_HOST ?? 'localhost',
port: Number(process.env.REDIS_PORT ?? 6379),
keyPrefix: 'catalog:prod:v2:',
},
},
http: {
baseURL: 'https://inventory.internal.example',
trustedOrigins: ['https://inventory.internal.example'],
timeout: 5_000,
},
}),
],
providers: [
{ provide: APP_INTERCEPTOR, useExisting: CacheInterceptor },
InventoryClient,
],
})
export class AppModule {}Registering APP_INTERCEPTOR is mandatory. Without it, @Cached() only attaches metadata and handlers execute normally on every request.
Tenant response cache
import { Controller, Get, Query } from '@nestjs/common';
import { Cached } from '@sdcorejs/nestjs/services';
@Controller('catalog')
class CatalogController {
@Get('search')
@Cached({
scope: 'tenant',
ttl: 30,
keyResolver: ({ query }) => ({
q: query.q,
page: query.page,
}),
})
search(@Query() query: { q?: string; page?: string }) {
return {
q: query.q ?? '',
page: Number(query.page ?? 0),
};
}
}The business suffix uses query values, but the interceptor still adds class/method and a mandatory tenant/domain/security namespace. Missing tenant identity bypasses the cache; it never falls back to an anonymous shared key.
Use scope: 'user' for user-private output. Use scope: 'global' only for output that is identical across tenant, user, language, roles, permissions, and all domain context.
Explicit cache invalidation
import { Injectable } from '@nestjs/common';
import { CacheService } from '@sdcorejs/nestjs/services';
@Injectable()
class ProductCache {
constructor(private readonly cache: CacheService) {}
detail(tenant: string, id: string, load: () => Promise<unknown>) {
return this.cache.load('product:' + tenant + ':' + id, load, 60);
}
invalidate(tenant: string, id: string) {
return this.cache.del('product:' + tenant + ':' + id);
}
}Direct keys are application-owned, so this example includes tenant explicitly.
Propagation checks
For a request context containing tenant ACME:
http.get('/stock')targets the configured base origin and receives generatedx-tenant: ACME;http.get('https://inventory.internal.example:8443/stock')is a different origin and receives no identity headers;http.get('https://external.example/stock')receives no identity headers; and- a redirect from the trusted origin to an external origin is stripped again.
The client does not generate Authorization or x-internal-secret. Caller-supplied internal secrets survive only for an exact trusted origin.
Tests to keep
- Same query, same tenant → one handler execution across concurrent misses.
- Same query, different tenant or permission version → different value at
scope: 'tenant'. - Different
userIdalone shares the tenant-scoped value;scope: 'user'isolates per user. - Missing or unsafe context → handler runs and no shared cache hit occurs.
- Trusted origin receives configured identity headers.
- Lookalike host, alternate port, and untrusted redirect receive none.
