@oficinabrasil/declarative-cache
v1.0.1
Published
Declarative cache decorators for TypeScript with Memory and Redis providers, tags, TTL and stampede protection.
Maintainers
Readme
Declarative Cache
Cache declarativo e reutilizável para TypeScript/Node.js, pensado para repositories e aplicações com DDD.
O pacote permite cachear, atualizar e invalidar resultados usando decorators, sem acoplar seus repositories a Redis, memória ou qualquer implementação específica.
@Cacheable({
key: (tenantId, id) => cacheKey('tenant', tenantId, 'user', id),
ttl: 300,
tags: (tenantId, id) => [
cacheKey('tenant', tenantId, 'users'),
cacheKey('tenant', tenantId, 'user', id),
],
})
async findById(tenantId: number, id: string) {
return this.prisma.user.findUnique({ where: { id } });
}Recursos
@Cacheablepara cachear retornos@CachePutpara atualizar o cache após writes@CacheInvalidatepara invalidar chaves ou tags- TTL por entrada
- invalidação por tags
- namespaces e suporte natural a multi-tenant
MemoryCacheProviderRedisCacheProvider- proteção contra cache stampede
- deduplicação local de promises
- distributed lock para Redis
- modo fail-open através de
ResilientCacheProvider unlesspara impedir cache de resultados específicos- estatísticas básicas
- provider totalmente desacoplado dos decorators
Requisitos
- Node.js 20+
- TypeScript 5+
experimentalDecorators: true
Para Redis, use um client compatível com a interface exposta por node-redis.
Instalação
npm install @oficinabrasil/declarative-cacheSe for utilizar Redis:
npm install redisConfiguração TypeScript
{
"compilerOptions": {
"target": "ES2022",
"module": "NodeNext",
"moduleResolution": "NodeNext",
"experimentalDecorators": true,
"strict": true
}
}Arquitetura
Repository
│
├── @Cacheable
├── @CachePut
└── @CacheInvalidate
│
▼
CacheProvider
/ \
/ \
▼ ▼
MemoryCache RedisCache
│
▼
RedisOs decorators conhecem apenas CacheProvider. Nenhum decorator sabe como Redis ou memória funcionam.
CacheProvider
export interface CacheProvider {
get<T>(key: string): Promise<T | null>;
set<T>(
key: string,
value: T,
options?: CacheSetOptions,
): Promise<void>;
delete(key: string): Promise<void>;
deleteMany(keys: string[]): Promise<void>;
invalidateTag(tag: string): Promise<void>;
invalidateTags(tags: string[]): Promise<void>;
getOrSet<T>(
key: string,
factory: () => Promise<T>,
options?: CacheGetOrSetOptions<T>,
): Promise<T>;
}Isso permite criar outros providers sem alterar seus repositories.
Exemplo completo com repository
import {
Cacheable,
CacheInvalidate,
CachePut,
CacheableRepository,
cacheKey,
} from '@oficinabrasil/declarative-cache';
interface User {
id: string;
tenantId: number;
name: string;
}
export class UserRepository extends CacheableRepository {
constructor(
private readonly prisma: PrismaClient,
cache: CacheProvider,
) {
super(cache);
}
@Cacheable<[number, string], User | null>({
key: (tenantId, id) =>
cacheKey('tenant', tenantId, 'user', id),
tags: (tenantId, id) => [
cacheKey('tenant', tenantId, 'users'),
cacheKey('tenant', tenantId, 'user', id),
],
ttl: 300,
unless: (result) => result === null,
})
async findById(
tenantId: number,
id: string,
): Promise<User | null> {
return this.prisma.user.findFirst({
where: {
id,
tenantId,
},
});
}
@Cacheable<[number], User[]>({
key: (tenantId) =>
cacheKey('tenant', tenantId, 'users', 'all'),
tags: (tenantId) => [
cacheKey('tenant', tenantId, 'users'),
],
ttl: 120,
})
async findAll(tenantId: number): Promise<User[]> {
return this.prisma.user.findMany({
where: { tenantId },
});
}
@CachePut<[number, string, string], User>({
key: ({ args: [tenantId, id] }) =>
cacheKey('tenant', tenantId, 'user', id),
tags: ({ args: [tenantId, id] }) => [
cacheKey('tenant', tenantId, 'users'),
cacheKey('tenant', tenantId, 'user', id),
],
ttl: 300,
})
async update(
tenantId: number,
id: string,
name: string,
): Promise<User> {
return this.prisma.user.update({
where: { id },
data: { name },
});
}
@CacheInvalidate<[number, string], void>({
tags: ({ args: [tenantId, id] }) => [
cacheKey('tenant', tenantId, 'users'),
cacheKey('tenant', tenantId, 'user', id),
],
})
async delete(
tenantId: number,
id: string,
): Promise<void> {
await this.prisma.user.delete({
where: { id },
});
}
}MemoryCacheProvider
Ideal para:
- desenvolvimento local
- testes
- aplicações de uma única instância
- caches locais e extremamente rápidos
import { MemoryCacheProvider } from '@oficinabrasil/declarative-cache';
const cache = new MemoryCacheProvider({
defaultTtl: 300,
maxEntries: 10_000,
});Cache stampede em memória
Chamadas simultâneas para a mesma chave compartilham a mesma Promise:
request A ──┐
request B ──┤
request C ──┤── MISS ── database
request D ──┘ │
▼
cacheApenas uma factory é executada por processo.
RedisCacheProvider
import { createClient } from 'redis';
import {
RedisCacheProvider,
ResilientCacheProvider,
} from '@oficinabrasil/declarative-cache';
const redis = createClient({
url: process.env.REDIS_URL,
});
await redis.connect();
const redisCache = new RedisCacheProvider(redis, {
prefix: 'crm',
defaultTtl: 300,
lockTtlMs: 10_000,
lockRetryMs: 50,
lockMaxWaitMs: 5_000,
});
const cache = new ResilientCacheProvider(redisCache, {
onError(error, operation) {
console.error({ operation, error }, 'Cache failure');
},
});Para aplicações em produção, recomenda-se envolver Redis com ResilientCacheProvider.
Assim, uma indisponibilidade do Redis não transforma uma operação válida do banco em erro de negócio.
Fail-open
Com o wrapper resiliente:
Redis online
│
└── cache funciona normalmente
Redis offline
│
├── GET vira cache miss
├── SET é ignorado
├── invalidation registra erro
└── repository continua funcionandoEsse comportamento é especialmente importante quando cache é uma otimização e não fonte de verdade.
Tags
Tags resolvem o problema de invalidação de consultas relacionadas.
Imagine três caches:
user:123
users:all
users:activeTodos podem estar relacionados à entidade User.
@Cacheable({
key: () => 'users:active',
tags: () => ['users'],
})@Cacheable({
key: (id) => `user:${id}`,
tags: (id) => ['users', `user:${id}`],
})Agora um write pode invalidar:
@CacheInvalidate({
tags: ({ args: [id] }) => [
'users',
`user:${id}`,
],
})Não é necessário fazer SCAN, wildcard ou conhecer todas as cache keys existentes.
Multi-tenant
Nunca compartilhe chaves entre tenants.
Ruim:
user:123Preferível:
tenant:30:user:123Use o helper:
import { tenantCacheKey } from '@oficinabrasil/declarative-cache';
const key = tenantCacheKey(30, 'user', '123');Resultado:
tenant:30:user:123@Cacheable
@Cacheable({
key: (id) => `user:${id}`,
ttl: 300,
})
async findById(id: string) {
// ...
}Fluxo:
method
│
▼
cache.get
│
├── HIT ───────────────► return
│
└── MISS
│
▼
factory
│
▼
cache.set
│
▼
returnNão cachear null
@Cacheable({
key: (id) => `user:${id}`,
ttl: 300,
unless: (result) => result === null,
})Também é possível cachear null se isso fizer sentido para seu domínio. Nesse caso, não use unless; porém, atualmente get() usa null como indicador de miss, então para negative caching prefira encapsular o resultado, por exemplo { found: false }.
@CacheInvalidate
A invalidação ocorre somente depois da operação original terminar com sucesso.
@CacheInvalidate({
tags: ({ args: [tenantId, id] }) => [
`tenant:${tenantId}:users`,
`tenant:${tenantId}:user:${id}`,
],
})
async delete(tenantId: number, id: string) {
// database delete
}Fluxo:
DELETE/UPDATE no banco
│
├── erro ─────► throw
│
▼
sucesso
│
▼
invalidar cache
│
▼
return@CachePut
Use quando a operação já retorna a versão atualizada da entidade.
@CachePut({
key: ({ args: [id] }) => `user:${id}`,
ttl: 300,
})
async update(id: string, input: UpdateUserInput) {
return database.update(id, input);
}Isso evita:
UPDATE
↓
DELETE CACHE
↓
GET
↓
MISS
↓
DATABASE novamenteE substitui por:
UPDATE
↓
novo resultado
↓
SET CACHETransformando o valor
@CachePut({
key: ({ result }) => `user:${result.id}`,
value: ({ result }) => ({
id: result.id,
name: result.name,
}),
})Chaves versus tags
Use key invalidation quando você conhece exatamente uma entrada.
@CacheInvalidate({
keys: ({ args: [id] }) => [`user:${id}`],
})Use tags quando várias queries podem depender da mesma entidade ou agregado.
@CacheInvalidate({
tags: ({ args: [tenantId] }) => [
`tenant:${tenantId}:users`,
],
})Estratégia sugerida de tags
Para uma entidade User em um sistema multi-tenant:
tenant:30:users
tenant:30:user:123Uma query individual pode usar ambas:
tags: (tenantId, id) => [
cacheKey('tenant', tenantId, 'users'),
cacheKey('tenant', tenantId, 'user', id),
]Uma listagem normalmente usa apenas a tag coletiva:
tags: (tenantId) => [
cacheKey('tenant', tenantId, 'users'),
]Proteção contra stampede
Memória
É feita por deduplicação de Promises dentro do processo.
Redis
O provider tenta adquirir um lock distribuído temporário:
cache:lock:<key>Somente o processo que obtiver o lock executa a factory inicialmente.
Os demais aguardam brevemente pelo preenchimento da cache key.
O lock possui TTL para evitar deadlocks permanentes.
Após o tempo máximo de espera, disponibilidade tem prioridade sobre deduplicação perfeita e a chamada executa a factory.
Desabilitando lock
@Cacheable({
key: (id) => `user:${id}`,
lock: false,
})Estrutura Redis
Para:
user:123o provider cria uma chave semelhante a:
cache:data:user:123Para a tag:
usersé utilizado um Redis Set:
cache:tag:usersque contém referências para as cache keys relacionadas.
Isso permite invalidação eficiente sem KEYS * ou SCAN de todo Redis.
Serialização
O provider Redis utiliza JSON.
Portanto, valores devem ser serializáveis.
Objetos como estes exigem tratamento específico antes de entrar no cache:
DateBigIntMapSet- classes com protótipo relevante
Para entidades de domínio ricas, prefira cachear DTOs ou snapshots serializáveis.
Transações
Um ponto importante: decorators não sabem automaticamente se sua operação está dentro de uma transação de banco.
Este cenário é perigoso:
BEGIN
UPDATE user
invalidar cache
ROLLBACKO banco voltou ao estado anterior, mas o cache já foi modificado.
Para operações transacionais complexas, prefira uma destas estratégias:
- aplicar decorators no método que representa a transação inteira;
- invalidar somente após commit;
- usar Unit of Work com callbacks
afterCommit; - publicar um evento de invalidação via Outbox após commit.
Em arquiteturas distribuídas, Transactional Outbox + invalidação assíncrona é a opção mais robusta quando consistência eventual é aceitável.
Cache não é fonte de verdade
O banco continua sendo a fonte de verdade.
Por isso a configuração recomendada em produção é:
Repository
│
Decorators
│
ResilientCacheProvider
│
RedisCacheProvider
│
RedisSe Redis cair, a aplicação continua consultando o banco.
Observabilidade
Providers expõem estatísticas básicas quando suportado:
console.log(cache.stats?.());Exemplo:
{
hits: 1000,
misses: 90,
sets: 88,
deletes: 12,
errors: 0,
}Em produção, recomenda-se exportar métricas como:
- cache hit rate
- misses
- latency do Redis
- quantidade de writes
- invalidations
- lock contention
- errors
- fallback para banco
Exemplo de bootstrap
Desenvolvimento
const cache = new MemoryCacheProvider({
defaultTtl: 300,
});Produção
const redis = createClient({
url: process.env.REDIS_URL,
});
await redis.connect();
const cache = new ResilientCacheProvider(
new RedisCacheProvider(redis, {
prefix: 'my-api',
defaultTtl: 300,
}),
{
onError(error, operation) {
logger.warn({ error, operation }, 'cache unavailable');
},
},
);Seu container de DI pode registrar ambos sob CacheProvider.
Organização do projeto
src/
├── contracts/
│ ├── cache-host.ts
│ ├── cache-provider.ts
│ ├── cache.types.ts
│ └── decorator.types.ts
│
├── decorators/
│ ├── cacheable.decorator.ts
│ ├── cache-invalidate.decorator.ts
│ └── cache-put.decorator.ts
│
├── providers/
│ ├── memory/
│ │ └── memory-cache.provider.ts
│ │
│ ├── redis/
│ │ ├── redis-cache.provider.ts
│ │ └── redis.types.ts
│ │
│ └── resilient-cache.provider.ts
│
├── utils/
│ ├── cache-error.ts
│ ├── cache-key.ts
│ └── require-cache.ts
│
└── index.tsTestes
npm testBuild
npm run buildSaída:
dist/Recomendações para produção
- use Redis para aplicações com mais de uma instância;
- use Memory apenas quando cache local for aceitável;
- mantenha tenant ID em todas as chaves multi-tenant;
- evite wildcard para invalidação;
- prefira tags;
- defina TTL mesmo quando houver invalidação explícita;
- use
ResilientCacheProviderpara Redis; - cacheie DTOs serializáveis, não objetos complexos;
- acompanhe hit ratio;
- faça invalidação somente após commit;
- use Outbox quando múltiplos serviços precisarem invalidar caches;
- não coloque cache em métodos que precisam de consistência forte sem analisar as consequências.
Próximas evoluções possíveis
- stale-while-revalidate (SWR)
- jitter automático no TTL para evitar expiração simultânea
- L1 Memory + L2 Redis
- invalidation events via Redis Pub/Sub
- OpenTelemetry
- métricas Prometheus
- adapters NestJS
- integração com Unit of Work /
afterCommit - serializers customizados
- versionamento de namespaces
- suporte a negative caching explícito
Licença
MIT
