Skip to content

Store-Backed Policies

Some policies need to maintain state across requests — counting requests for rate limiting, tracking failures for circuit breaking, caching responses. These require stores that persist data beyond a single request lifecycle. Stoma’s adapter pattern lets you swap store implementations (in-memory for development, KV or Durable Objects for production) without changing any policy code.

The GatewayAdapter interface is the bridge between policies and their backing stores. You set an adapter on GatewayConfig, and the gateway injects it into every request’s PolicyContext. Policies access it via getGatewayContext(c):

import { getGatewayContext } from "@homegrower-club/stoma";
// Inside a policy handler:
const adapter = getGatewayContext(c)?.adapter;
if (!adapter?.rateLimitStore) {
// No store available - degrade gracefully
await next();
return;
}

The adapter carries optional fields for each built-in store type:

interface GatewayAdapter {
rateLimitStore?: RateLimitStore;
circuitBreakerStore?: CircuitBreakerStore;
cacheStore?: CacheStore;
waitUntil?: (promise: Promise<unknown>) => void;
dispatchBinding?: (service: string, request: Request) => Promise<Response>;
}

Walk through building a request counter policy step by step — defining the store interface, implementing an in-memory version, wiring it through the adapter, and using it in a policy.

Start with a minimal contract. Keeping it abstract lets you swap implementations later without touching the policy.

interface CounterStore {
increment(key: string): Promise<number>;
get(key: string): Promise<number>;
}

For development and browser-based playground demos, an in-memory store is all you need:

class InMemoryCounterStore implements CounterStore {
private counts = new Map<string, number>();
async increment(key: string): Promise<number> {
const current = (this.counts.get(key) ?? 0) + 1;
this.counts.set(key, current);
return current;
}
async get(key: string): Promise<number> {
return this.counts.get(key) ?? 0;
}
}

Extend GatewayAdapter with your custom store field. TypeScript will enforce that any code accessing it goes through the adapter, keeping the policy decoupled from the implementation.

import type { GatewayAdapter } from "@homegrower-club/stoma";
interface AppAdapter extends GatewayAdapter {
counterStore?: CounterStore;
}

Use safeCall from the SDK to wrap store operations. If the store throws (network timeout, serialization error, etc.), the request continues instead of failing hard.

import { definePolicy, Priority } from "@homegrower-club/stoma";
import { safeCall } from "@homegrower-club/stoma/sdk";
import { getGatewayContext } from "@homegrower-club/stoma";
const requestCounter = definePolicy({
name: "request-counter",
priority: Priority.OBSERVABILITY,
handler: async (c, next, { debug }) => {
const adapter = getGatewayContext(c)?.adapter as AppAdapter | undefined;
const store = adapter?.counterStore;
let count = 0;
if (store) {
count = await safeCall(
() => store.increment(c.req.path),
0,
debug,
"counterStore.increment()",
);
debug("request #%d to %s", count, c.req.path);
}
await next();
if (count > 0) {
c.res.headers.set("x-request-count", String(count));
}
},
});

safeCall(fn, fallback, debug?, label?) catches any error thrown by fn, logs it through the debug logger with the provided label, and returns the fallback value. The request proceeds as if the store did not exist.

Try the counter policy

Hit /api/count several times. Each response includes an x-request-count header with the running total for that path.

Some in-memory stores run periodic cleanup to evict expired entries. InMemoryRateLimitStore, for example, starts a setInterval that sweeps stale sliding-window entries every 60 seconds by default. If you use these stores in tests, you must call .destroy() in your teardown to clear the interval — otherwise the test process hangs or leaks timers:

import { InMemoryRateLimitStore } from "@homegrower-club/stoma";
const store = new InMemoryRateLimitStore();
// ... use the store in tests ...
afterAll(() => {
store.destroy(); // clears the cleanup interval
});

If your custom store uses intervals or open connections, follow the same pattern: expose a destroy() method and document the cleanup requirement.

Some store operations (writing a cache entry, persisting metrics) should complete even after the HTTP response is sent. The adapter provides an optional waitUntil function for this. On Cloudflare Workers it maps to executionCtx.waitUntil(); on other runtimes it may be a no-op or collect promises for manual draining.

const adapter = getGatewayContext(c)?.adapter;
if (adapter?.waitUntil) {
adapter.waitUntil(store.persistAsync(key, value));
}

The in-memory stores are great for development, but production workloads need durable storage. Stoma ships adapters for several runtimes:

  • Cloudflare KV — eventually consistent, high-throughput counter storage. See KV Rate Limiting.
  • Cloudflare Durable Objects — strongly consistent state for circuit breakers and coordination. See Durable Objects.
  • Deno, Bun, Node — runtime-specific adapters available at @homegrower-club/stoma/adapters/deno, @homegrower-club/stoma/adapters/bun, and @homegrower-club/stoma/adapters/node.

Each adapter implements the same GatewayAdapter interface. Swap the adapter in your gateway config and every store-backed policy picks up the new implementation automatically.