Adds ElastiCache Redis with reusable client, tenant-isolated sessions, and cache-aside pattern for service methods
Adds ElastiCache Redis with a reusable client across Lambda invocations, tenant-isolated session management, and a cache-aside decorator pattern for service methods.
This is an agent-agnostic skill. Load it as context for your preferred AI agent.
Claude Code — copy to .claude/skills/ and run:
/add-redis-cache
Other agents (Codex, Cursor, Copilot, etc.) — reference in your prompt:
Use the skill in
skills/add-redis-cache/SKILL.mdto add Redis caching to this microservice
Run inside an existing microservice (created with create-lambda-service).
Add the files below to implement Redis caching following the Softplan platform patterns.
import Redis, { Redis as RedisClient } from 'ioredis';
let client: RedisClient | null = null;
/**
* Returns a reusable Redis client across Lambda invocations.
* The client is created once and kept in the container scope (warm Lambda).
*/
export function getRedisClient(): RedisClient {
if (!client) {
client = new Redis({
host: process.env.REDIS_HOST!,
port: parseInt(process.env.REDIS_PORT ?? '6379', 10),
tls: process.env.NODE_ENV === 'production' ? {} : undefined,
// Timeouts suitable for Lambda (avoids stuck functions)
connectTimeout: 5000,
commandTimeout: 3000,
maxRetriesPerRequest: 3,
retryStrategy(times) {
if (times > 3) return null; // Do not block Lambda on Redis failure
return Math.min(times * 200, 1000);
},
lazyConnect: true,
});
client.on('error', (err) => {
console.error('[Redis] Connection error:', err.message);
});
}
return client;
}
import { getRedisClient } from './client';
import { TenantContext } from '../types/tenant';
const SESSION_TTL = parseInt(process.env.SESSION_TTL ?? '3600', 10); // 1 hour default
export interface SessionData {
userId: string;
tenantId: string;
email: string;
roles: string[];
permissions: string[];
lastActivity: string;
[key: string]: unknown;
}
export class SessionService {
private redis = getRedisClient();
/**
* Tenant-isolated session key to prevent cross-tenant collisions.
*/
private buildSessionKey(tenantId: string, sessionId: string): string {
return `session:${tenantId}:${sessionId}`;
}
async get(tenantContext: TenantContext, sessionId: string): Promise<SessionData | null> {
const key = this.buildSessionKey(tenantContext.tenantId, sessionId);
const data = await this.redis.get(key);
if (!data) return null;
// Renew TTL on each access (sliding expiration)
await this.redis.expire(key, SESSION_TTL);
return JSON.parse(data) as SessionData;
}
async set(
tenantContext: TenantContext,
sessionId: string,
data: Partial<SessionData>
): Promise<void> {
const key = this.buildSessionKey(tenantContext.tenantId, sessionId);
const session: SessionData = {
userId: tenantContext.userId,
tenantId: tenantContext.tenantId,
email: '',
roles: tenantContext.roles,
permissions: tenantContext.permissions,
lastActivity: new Date().toISOString(),
...data,
};
await this.redis.setex(key, SESSION_TTL, JSON.stringify(session));
}
async delete(tenantContext: TenantContext, sessionId: string): Promise<void> {
const key = this.buildSessionKey(tenantContext.tenantId, sessionId);
await this.redis.del(key);
}
async exists(tenantContext: TenantContext, sessionId: string): Promise<boolean> {
const key = this.buildSessionKey(tenantContext.tenantId, sessionId);
return (await this.redis.exists(key)) === 1;
}
}
import { getRedisClient } from './client';
import { TenantContext } from '../types/tenant';
const DEFAULT_TTL = parseInt(process.env.CACHE_DEFAULT_TTL ?? '300', 10); // 5 minutes
export class CacheService {
private redis = getRedisClient();
/**
* Tenant-isolated cache key.
* Pattern: cache:{tenant_id}:{namespace}:{identifier}
*/
private buildKey(tenantId: string, namespace: string, identifier: string): string {
return `cache:${tenantId}:${namespace}:${identifier}`;
}
async get<T>(tenantContext: TenantContext, namespace: string, key: string): Promise<T | null> {
const redisKey = this.buildKey(tenantContext.tenantId, namespace, key);
const data = await this.redis.get(redisKey);
return data ? (JSON.parse(data) as T) : null;
}
async set<T>(
tenantContext: TenantContext,
namespace: string,
key: string,
value: T,
ttlSeconds = DEFAULT_TTL
): Promise<void> {
const redisKey = this.buildKey(tenantContext.tenantId, namespace, key);
await this.redis.setex(redisKey, ttlSeconds, JSON.stringify(value));
}
async invalidate(tenantContext: TenantContext, namespace: string, key: string): Promise<void> {
const redisKey = this.buildKey(tenantContext.tenantId, namespace, key);
await this.redis.del(redisKey);
}
/**
* Invalidates all cache entries for a namespace within a tenant.
* Useful for cascade invalidation after updates.
*/
async invalidateNamespace(tenantContext: TenantContext, namespace: string): Promise<void> {
const pattern = `cache:${tenantContext.tenantId}:${namespace}:*`;
const keys = await this.redis.keys(pattern);
if (keys.length > 0) {
await this.redis.del(...keys);
console.log(`[Cache] Invalidated ${keys.length} keys from namespace ${namespace}`);
}
}
/**
* Cache-aside pattern: reads from cache, falls back to the fetcher, and stores the result.
* Reduces database load without duplicating logic across services.
*/
async getOrSet<T>(
tenantContext: TenantContext,
namespace: string,
key: string,
fetcher: () => Promise<T>,
ttlSeconds = DEFAULT_TTL
): Promise<T> {
const cached = await this.get<T>(tenantContext, namespace, key);
if (cached !== null) {
return cached;
}
const value = await fetcher();
await this.set(tenantContext, namespace, key, value, ttlSeconds);
return value;
}
}
import { CacheService } from '../cache/cache.service';
import { {Name}Repository } from '../repositories/{name}.repository';
import { TenantContext } from '../types/tenant';
import { NotFoundError } from '../utils/errors';
const CACHE_NAMESPACE = '{name}';
const CACHE_TTL = 300; // 5 minutes
export class {Name}Service {
private repository: {Name}Repository;
private cache: CacheService;
constructor() {
this.repository = new {Name}Repository();
this.cache = new CacheService();
}
async findById(tenantContext: TenantContext, id: string): Promise<unknown> {
// Try cache first
return this.cache.getOrSet(
tenantContext,
CACHE_NAMESPACE,
id,
async () => {
const item = await this.repository.findById(tenantContext, id);
if (!item) throw new NotFoundError('Item');
return item;
},
CACHE_TTL
);
}
async update(tenantContext: TenantContext, id: string, data: unknown): Promise<unknown> {
const item = await this.repository.update(tenantContext, id, data);
// Invalidate cache after update
await this.cache.invalidate(tenantContext, CACHE_NAMESPACE, id);
return item;
}
async remove(tenantContext: TenantContext, id: string): Promise<void> {
await this.repository.delete(tenantContext, id);
// Invalidate cache after removal
await this.cache.invalidate(tenantContext, CACHE_NAMESPACE, id);
}
}
REDIS_HOST=softplan-cache.xxxx.use1.cache.amazonaws.com
REDIS_PORT=6379
SESSION_TTL=3600
CACHE_DEFAULT_TTL=300
ElastiCache Redis does not use IAM policies for data-plane authentication (it uses Security Groups). Make sure:
REDIS_HOST and REDIS_PORT in .env and Parameter StoreCacheService into services for frequently read operationsSessionService to manage authenticated user sessions