Redis cache layer
Primebrick provides an optional Redis cache layer for hot single-row reads.
It is a feature, not a requirement — the system is fully valid without it. If
Redis is not configured or unreachable, the system runs exactly as today
(DB-only), with warn logs (never error).
What gets cached
- Only single-row finders:
findById,findByUUID,find. - Only entities marked
@Cached(). Other entities pass through untouched. findAllandfindByPageare NOT cached — high-cardinality keys, memory bomb risk on large tables, stale-on-write window dangerous for list views. Cache dropdown/autocomplete lists at the BE application level with hand-writtenbe:dropdowns:*keys via the sameCachePort.
What does NOT get cached (and why)
| Operation | Cached? | Reason |
|---|---|---|
findById | Yes | Single row, stable key from result row |
findByUUID | Yes | Single row, input IS the cache key |
find | Yes | Returns 1 row by construction (limit: 1) |
findAll | No | List — high cardinality, stale risk |
findByPage | No | Paginated list — same as above |
Writes (add, update, …) | No (invalidate) | Writes go DB-first, then invalidate |
Enable Redis
redis_url is a new optional key in the auth_configurations table. Empty or
missing = cache disabled. Set it to your Redis URL:
Code
The BE reads redis_url at startup via loadAuthConfig. If Redis is
unreachable, the BE logs a warn and continues without cache — no fail-fast,
no crash.
Microservices do NOT set
redis_urlin their own config tables. They discover it from the BE via the NATSconfig.getprotocol (see Redis for microservices below). The SQLINSERTabove applies only to the BE'sauth_configurationstable.
Redis for microservices (mandatory SDK pattern)
Microservices must connect to Redis if they use @Cached() entities. Without
a shared Redis connection, cache invalidation from the BE (or other
microservices) cannot propagate, and the microservice would serve stale data
from its own Redis reads.
redis_url is stored only in the BE's auth_configurations table — it is
never duplicated in microservice config tables. Microservices discover it from
the BE via the NATS config.get request/reply protocol (see Shared config
protocol below).
The SDK provides initCacheFromSharedConfig(natsClient, logger) as a one-liner
that handles the NATS request, Redis connection, version logging, and error
handling. Microservices MUST use this instead of calling createRedisClient
directly with a hard-coded URL.
Microservice side (one-liner)
Code
If a microservice does not use @Cached() entities, Redis is optional — but
calling initCacheFromSharedConfig is still recommended so the startup banner
is consistent across all services.
BE side (subscribe to config.get)
The BE subscribes to config.get on NATS and responds with the shared config
object. This is done in the BE's startup sequence, right after the NATS
connection is established:
Code
The getConfig function is called on each request — it reads from the BE's
in-memory auth config (already loaded at startup via loadAuthConfig).
Shared config protocol (NATS config.get)
config.get is a generic NATS subject for sharing configuration from the BE
to microservices. It is not Redis-specific — future shared config fields
(s3_url, feature_flags, etc.) can be added to the SharedConfig interface
without changing the protocol or breaking consumers.
The SharedConfig interface
Code
All fields are optional: the BE only includes what it has configured. The microservice checks individual fields rather than assuming the whole object is populated.
Request/reply flow
Timeout and best-effort behavior
The NATS request has a 5-second timeout. If the BE doesn't respond (e.g. BE is
still starting up, or NATS is flaky), fetchSharedConfig returns an empty
object {}. The microservice continues without Redis — all cache calls are
no-ops. The system is fully valid without Redis.
Direct API (advanced)
For cases where the one-liner initCacheFromSharedConfig is too coarse, the
SDK exposes the building blocks:
Code
Health endpoint & version logging
Startup banner
Both the BE and microservices log a startup banner showing the Redis connection status and server version:
| State | Log output |
|---|---|
| Connected | [cache] Redis connected (v7.4.0) |
| Connected (version unknown) | [cache] Redis connected (version unknown) |
| Not configured | [cache] redis_url not set — cache disabled (best-effort, system valid without it) |
| Not received from BE (microservice) | [cache] redis_url not received from BE — cache disabled (best-effort) |
| Connection failed | [cache] Redis connection failed — cache disabled: <error> |
The version is queried via getRedisInfo(redis) which calls the Redis INFO
command and parses redis_version from the # Server section.
BE health endpoint
The BE's GET /api/v1/health now includes a redis field:
Code
When Redis is not configured or unreachable:
Code
The URL is never exposed in the health endpoint — only the connection status and server version.
FE VersionsPanel
The Frontend's VersionsPanel (sidebar sheet) shows a "Redis" row after the Identity Provider row, with:
- An "Online" badge (green) or "Offline" badge (red)
- A version badge (e.g.
7.4.0) orunknown
The Redis URL is not shown in the FE — it is only in the BE console log.
Mark an entity as cacheable
Import @Cached and @CacheKey from @primebrick/sdk and decorate your
entity:
Code
Choosing a TTL
@Cached()with no argument = no TTL, immutable data only. Use this ONLY for genuinely immutable data (the cached value can never change). There is NO implicit default — omitting the TTL is a deliberate statement that the data is immutable.@Cached(300_000)= 5 minutes. Recommended starting point for mutable data. The TTL bounds the staleness window if Redis is intermittently unavailable during invalidation.- The TTL is a correctness parameter, not just a performance one. Pick a TTL that bounds how stale a read can be in the worst case (Redis down during a write invalidation).
Choosing the cache key
CacheKeyBuilder derives the key from the result row, never from the
input argument. Resolution order:
- The property marked
@CacheKey()→dal:{table}:{row[propertyKey]} - Else
row.uuid(JS property convention) →dal:{table}:{row.uuid} - Else the
@Key()column (read via Reflect) →dal:{table}:{row[keyPropertyKey]} - Else throw — add
@CacheKey()to the property to use as the cache key.
Why result-row-derived keys? findById(42) and findByUUID(<uuid>) on
the same row produce the SAME cache key. This avoids duplicate entries and
ensures invalidation works correctly.
When to use @CacheKey() explicitly:
Code
Use @CacheKey() when:
- The entity has no
uuidproperty. - You want the cache key to match the FE-facing identifier (usually
uuid— but@CacheKeymakes it explicit). - The entity has multiple unique columns and you want a predictable key.
Wire the cache into your Repository
The withCache wrapper is opt-in. Call it once at bootstrap:
Code
If redis_url is empty or Redis is unreachable, skip withCache — the bare
Repository works exactly as before.
Failure behavior (best-effort)
The withCache wrapper NEVER lets a cache failure break a request:
| Failure | Behavior |
|---|---|
| Redis down on read | warn log, fall through to DB, return the row |
| Redis down on write | DB write succeeds first, invalidation fails with warn |
| Redis down on hydrate | Read returns the DB row; set failure is fire-and-forget |
CacheKeyBuilder throws | warn log, fall through to DB |
The caller never sees a cache error. All failures are warn logs, never
error. The cache is a feature, not a requirement.
Serialization
Cache values are serialized with the SDK's canonical extJsonStringify /
extJsonParse (json-bigint, useNativeBigInt: true) — the same serializer
used for HTTP responses and NATS™ messages. bigint PKs and Date fields
round-trip correctly. No custom $bigint: hack.
See Ext-JSON for details on the serialization format.
Cache keys
Keys are namespaced as dal:{tableName}:{identifier}:
| Entity | Key example |
|---|---|
CustomerEntity (@Entity("customers")) | dal:customers:abc-123-uuid |
IdpCodeMapEntity (@Entity("idp_code_map"), @CacheKey() idp_code) | dal:idp_code_map:ACME |
Entity without @Entity (no DAL) | dal:MyClass:abc-123 (falls back to class name) |
The table name is read from the DAL's @Entity decorator via standard JS
reflection (Reflect.getMetadata). The SDK has zero dependency on the DAL
— no import, no package dependency. The DAL is completely untouched by the
cache layer.
Multi-instance BE (pods behind a load balancer)
Redis is the single shared cache. When pod #1 invalidates a key, pod #2's next read sees the miss in Redis and re-hydrates from PostgreSQL. There is no cross-pod stale-cache problem. No NATS™ invalidation broadcaster is needed.
An L1 in-process cache in front of Redis is deferred — it would require a NATS™ broadcaster to stay consistent across pods, and Redis latency is not measurably painful at fewer than 5 pods. If L1 becomes needed, it will be a separate plan.
Under the hood (for contributors)
- The cache module lives entirely in
@primebrick/sdk(src/cache/). The DAL is NOT involved — it has zero cache knowledge. - The SDK reads entity metadata (table name, key column) via
Reflect.getMetadata("primebrick:tableName", ctor)— the DAL's@Entityand@Keydecorators write this metadata viaReflect.defineMetadata. No package dependency between the SDK and the DAL. @Cachedand@CacheKeyuse the SDK's own WeakMap — separate from the DAL'sClassEntityMeta. Two metadata systems coexist without interacting.withCacheuses a structuralCacheableRepositoryinterface — TypeScript® structural typing means a DALRepositoryis assignable without anyimport typefrom the DAL.RedisCachePortusesnode-redis(theredisnpm package, v6.x) — the official Redis client, recommended by Redis org for new projects.
Next steps
- Ext-JSON — the serialization layer used by the cache
- Authentication — auth modes, RBAC, session context
- API Reference — every exported symbol, including
CachePort,CacheKeyBuilder,Cached,CacheKey,withCache,RedisCachePort