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

Add Redis caching to the Quickstart app with the preset:

bun beignet provider add cache-redis
bun install

The preset adds the provider to server/providers.ts, declares the cache port, and writes its environment entries to .env.example. Set REDIS_URL in .env.local to your reachable Redis service, then verify and restart:

bun beignet provider audit
bun beignet doctor --strict
bun run dev

The existing Todos routes and authentication should continue to work. After boot, ctx.ports.cache is available to use cases. Use the testing example to verify code that consumes it without connecting tests to Redis.

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:

Replace the preset's createRedisCacheProvider() entry in server/providers.ts with this configured call (excerpt):

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.