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 ioredis
import { 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],
createContext: ({ ports }) => ({ ports }),
});
The provider reads REDIS_URL and optional REDIS_DB from environment
variables and installs ctx.ports.cache.
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 exposes the underlying ioredis client for operations the
stable cache port does not model:
import type { RedisCachePort } from "@beignet/provider-redis";
const cache = ctx.ports.cache as RedisCachePort;
await cache.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.
Devtools
When the devtools provider is installed before the Redis provider, cache
operations appear in the Cache tab. Redis records cache.get, cache.set,
cache.delete, cache.has, and cache.remember with the key, hit/miss or
deleted status, TTL, and duration. 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.