Cache
Cache is an application dependency behind CachePort. Use it when a workflow
can reuse expensive reads, keep short-lived computed data, or share lightweight
state across requests without coupling use cases to Redis.
The important boundary is simple: application code talks to ctx.ports.cache;
the runtime chooses the adapter.
Setup
Use the Redis provider when production needs a shared cache:
bun add @beignet/provider-cache-redis ioredisimport { createNextServer, createNextServerLoader } from "@beignet/next";
import { createRedisCacheProvider } from "@beignet/provider-cache-redis";
import { initialPorts } from "@/infra/port-wiring";
export const getServer = createNextServerLoader(() =>
createNextServer({
ports: initialPorts,
providers: [createRedisCacheProvider()],
context: appContextBlueprint,
}),
);The provider reads REDIS_URL and optional REDIS_DB,
REDIS_CONNECT_TIMEOUT_MS, REDIS_SHUTDOWN_TIMEOUT_MS,
REDIS_MAX_RETRIES_PER_REQUEST, and REDIS_CONNECT_MAX_ATTEMPTS from
environment variables and installs
ctx.ports.cache. Startup fails fast with a clear error when Redis is
unreachable instead of retrying forever; after a successful connection, lost
connections reconnect with capped exponential backoff.
Use createRedisCacheProvider(options) when the app should own connection
defaults. Defined options override matching environment variables:
import { createRedisCacheProvider } from "@beignet/provider-cache-redis";
const provider = createRedisCacheProvider({
connectTimeoutMs: 2000,
shutdownTimeoutMs: 2000,
maxRetriesPerRequest: 1,
});Environment-backed numeric values and matching number options must be safe integers. Connection and shutdown timeouts cannot exceed 2,147,483,647 milliseconds, the JavaScript timer ceiling; shutdown timeouts must be positive.
When app infrastructure already owns a connected ioredis client, adapt it without provider lifecycle or environment loading:
import { createRedisCache } from "@beignet/provider-cache-redis";
const cache = createRedisCache({ client: appRedis });The caller owns connection, retries, health checks, and shutdown in this mode.
Pass instrumentation to preserve cache provider events.
For provider-owned clients, REDIS_SHUTDOWN_TIMEOUT_MS defaults to 5000.
server.stop() rejects when graceful Redis shutdown fails or exceeds that
deadline, after attempting a forced disconnect. Process entrypoints should log
that failure and exit unsuccessfully.
Port API
CachePort stores string values:
export interface CachePort {
get(key: string): Promise<string | null>;
set(
key: string,
value: string,
options?: { ttlSeconds?: number },
): Promise<void>;
delete(key: string): Promise<boolean>;
has(key: string): Promise<boolean>;
remember(
key: string,
factory: () => Promise<string>,
options?: { ttlSeconds?: number },
): Promise<string>;
}Omit ttlSeconds when a value should persist until explicit invalidation. When
provided, ttlSeconds must be a positive safe integer, so memory and Redis
adapters interpret the same write identically. Zero, negative, fractional,
non-finite, and unsafe integer values throw before the cache is read or
written.
Custom cache adapters can call resolveCacheTtlSeconds(options) from
@beignet/core/ports before reading or writing. The helper returns undefined
for a persistent value and throws for an invalid TTL.
Keep serialization at the application boundary so cached values stay typed:
import { z } from "zod";
const ProjectSummarySchema = z.object({
id: z.string(),
name: z.string(),
openIssueCount: z.number().int().nonnegative(),
});
export async function getProjectSummary(ctx: AppContext, projectId: string) {
const key = `project:${projectId}:summary`;
const serialized = await ctx.ports.cache.remember(
key,
async () => {
const summary = await ctx.ports.projects.getSummary(projectId);
return JSON.stringify(summary);
},
{ ttlSeconds: 60 },
);
return ProjectSummarySchema.parse(JSON.parse(serialized));
}Key conventions
Use predictable keys that include the resource and scope:
const projectKey = `project:${projectId}`;
const userFeedKey = `user:${userId}:feed`;
const tenantStatsKey = `tenant:${tenantId}:stats:${day}`;Prefer short TTLs for derived reads. Use explicit invalidation when writes make cached data stale:
await ctx.ports.projects.update(projectId, input);
await ctx.ports.cache.delete(`project:${projectId}:summary`);If invalidation becomes hard to reason about, move the invalidation rule into the use case or an event listener so HTTP routes, jobs, scripts, and tests all share it.
Escape hatch
The Redis provider contributes ctx.ports.redis with the underlying ioredis
client for operations the stable cache port does not model:
await ctx.ports.redis.client.incr("project:created-count");Use the stable CachePort for normal application behavior. Use the raw client
only when the Redis-specific operation is intentional. See
escape hatches for the convention.
Devtools
Cache operations appear in the Cache view of devtools when the devtools provider is installed before the Redis provider. Cached values are not recorded.
Testing
Tests can use the first-party in-memory adapter instead of booting Redis:
import { createMemoryCache } from "@beignet/core/ports";
const cache = createMemoryCache();This keeps tests focused on cache behavior without depending on networked infrastructure.
Memory cache state is per-process: entries written by a script or another process are invisible to the dev server. See Process boundaries of memory providers.