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-redis ioredisimport { createNextServer } from "@beignet/next";
import { redisProvider } from "@beignet/provider-redis";
import { appPorts } from "@/infra/app-ports";
export const server = await createNextServer({
ports: appPorts,
providers: [redisProvider],
context: appContextBlueprint,
});The provider reads REDIS_URL and optional REDIS_DB,
REDIS_CONNECT_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 createRedisProvider(options) when the app should own connection
defaults. Environment variables still win when both are set:
import { createRedisProvider } from "@beignet/provider-redis";
const redisProvider = createRedisProvider({
connectTimeoutMs: 2000,
maxRetriesPerRequest: 1,
});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>;
}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.