Adding A New Durable Object Mixin
Use this before adding or changing a mixin under packages/shared/src/durable-object-utils.
The default is do not code immediately. First spec the API with the user. If the shape is unclear, use the grill-me skill and ask direct design questions until the runtime behavior, type surface, and tests are obvious.
Ground Rules#
- Keep
DurableObjectas the root. Do not add a separate root class,pipe,flow, or builder unless explicitly specced. - Prefer nested composition:
withB(withA(withDurableObjectCore(DurableObject)))when the stack needs local storage, alarms, or lifecycle hooks. - Preserve Cloudflare-style ergonomics:
const Base = withX(...)(withDurableObjectCore(DurableObject)); class Room extends Base<Env> {}. - Keep env requirements and capability requirements separate.
- Use the smallest public API that solves the problem. Do not add optional knobs for hypothetical users.
- If a mixin can own a generic loop, configure the mixin with the concrete pieces instead of pushing the loop into every subclass. A subclass should not contain domain-specific copy/paste just because the mixin only exposed low-level protected helpers.
- Avoid inventing extra nouns. If the user has settled on "runner", don't introduce "adapter", "mount", "runtime", "slot", or similar names unless there is a separately explainable concept.
- Document every non-obvious type expression with the call-site benefit it protects.
- Add examples to
README.md;AGENTS.mdin this folder is a symlink to that README, so agents and humans share the same guidance.
Reference Implementations#
mixins/with-durable-object-core.ts: root adapter for Cloudflare's protectedctxAPIs. It exposes protected local SQLite, synchronous KV, and platform alarm capabilities so feature mixins do not reach intoctxdirectly.mixins/with-lifecycle-hooks.ts: protected subclass surface, named initialization, first-initialize/start hooks, static/generic preservation throughDurableObjectClass, and optional lifecycle-owned D1 object catalog projection.mixins/with-kv-inspector.ts: fetch wrapper that preserves genericBase<Env>.mixins/with-outerbase.ts: fetch wrapper around SQLite debug routes.mixins/with-lifecycle-hooks.type.test.ts: expect-type examples that prove lifecycle, env lower-bound, and protected-surface type incantations still work.test-harness/initialize-fronting-worker.ts: shared Worker entrypoint for worker-pool unit tests and deployed E2E.README.md: human/agent docs for composition, type shapes, runtime behavior, and test commands.
Design Checklist#
Before editing, write down:
- What route/RPC/method/state does the mixin add?
- Does it wrap
fetch, override/extend an existing method, or only add new methods? - If it overrides
alarm,fetch, or another runtime hook, does it call the inherited implementation and document what subclasses above it must do? - Is the mixin a low-level capability provider, or should it be configured with concrete callbacks and own a complete generic operation? Prefer the latter when otherwise every subclass would write the same loop.
- If the mixin is configured, which configured values are created once per Durable Object wake and cached? Document why cached closure state is safe and what durable state still belongs in storage.
- Does the mixin need to support multiple configured items at once, or should it
be stackable? Don't assume either shape. Compare the call sites:
withX({ items: { a, b } })(Base)vswithX({ item: a })(withX({ item: b })(Base)). - Does it require env bindings? If yes, what is the minimum env shape?
- Does it require members from earlier mixins? If yes, what exact capability interface should the base satisfy?
- Does it need local SQLite, synchronous KV, or platform alarms? If yes, depend on
DurableObjectCoreProtectedfromwithDurableObjectCore()instead of reaching intoctxdirectly. - Is any work intentionally allowed to continue after the triggering method returns?
If yes, pass a narrow
waitUntil(promise)capability or usectx.waitUntilat the Durable Object boundary, and document what observable event records the eventual result. - What should happen if a caller uses the mixin in production by accident?
If any answer is fuzzy, stop and grill the user. Do not encode uncertainty as optional config.
Type Pattern#
Name each mixin implementation file after the mixin function in kebab-case:
with-thing.ts for withThing().
For a simple member-adding mixin with no new env requirement, use the Cloudflare withVoice style:
type WithThingResult<TBase extends DurableObjectConstructor> = TBase & Constructor<ThingMembers>;TBase preserves the original class value, including statics and the generic Base<Env> shape. Constructor<ThingMembers> adds the instance methods.
For a mixin that must preserve accumulated env requirements and members through multiple wrappers, use the explicit generic Durable Object constructor pattern:
type DurableObjectClass<ReqEnv = unknown, Members = object> = abstract new <Env extends ReqEnv>(
ctx: DurableObjectState,
env: Env,
) => DurableObject<Env> & Members;Use comments to explain:
ReqEnvis the minimum env shape required by mixins applied so far.Membersis the instance surface accumulated so far.- The purpose is to keep
class Room extends Base<Env>valid after composition.
Feature mixins should generally consume protected capabilities from lower layers
instead of touching Cloudflare ctx directly. For example, withOuterbase()
and withKvInspector() require Constructor<DurableObjectCoreProtected> and use
this.useDurableObjectSql(...) / this.useDurableObjectKv(...), while
withDurableObjectCore() is the only reusable layer that adapts
ctx.storage.sql, ctx.storage.kv, and platform alarms.
When the mixin itself owns the operation, put the important call-site API at the
top of the file. Keep the lower-level protected helpers below it or private. For
example, withLifecycleHooks(...) is configured with a nameSchema and a
d1ObjectCatalog setting (with getDatabase(env) and indexes), then owns the
whole startup gate and catalog projection: it exposes initialize(),
ensureStarted(), assertInitialized(), and getD1ObjectCatalogRecord(). The
app Durable Object should not also contain the initialization/catalog loop.
Configured mixins often need cached values. Store them under explicit private
fields with names scoped to the mixin, the way withLifecycleHooks() keeps
#structuredName, #instanceWakePromise, and #firstInitializeHooks.
This avoids accidental collisions and makes future stackability easier to reason
about. If a mixin may be applied more than once, use unique method names or a
single configured-map shape; don't rely on vague super chaining without a
documented call order.
When a mixin needs one storage operation, prefer the scoped callback helpers:
return this.useDurableObjectKv((kv) => Response.json(readKvEntries(kv)));Only use the raw protected storage handles when the mixin owns durable state and needs several related operations in one method. This keeps debug/fetch helpers from passing raw Cloudflare storage handles through unrelated rendering code.
For env bindings, prefer a lower-bound type selected by the call site:
type NeedsCatalog = {
DO_CATALOG: D1Database;
};
const Base = withLifecycleHooks<RoomInit, undefined, NeedsCatalog>({
d1ObjectCatalog: {
className: "Room",
getDatabase(env) {
return env.DO_CATALOG;
},
},
nameSchema: RoomInit,
})(withDurableObjectCore(DurableObject));
class Room extends Base<NeedsCatalog & { OTHER: string }> {}Avoid app-wide Env in the library if a smaller binding fragment is enough.
Documentation Requirements#
Every mixin needs:
- A top-level JSDoc saying what it adds and what routes/methods it owns.
- A clear warning if it exposes storage, SQL, debug state, or unsafe behavior.
- Comments beside type aliases when preserving
TBase,ReqEnv,Members, protected members, or env lower-bounds. - README examples showing the call-site benefit, not just implementation details.
- A note explaining any detached promises, synchronous KV, SQLite, or D1 behavior that is not obvious.
Do not write “magic” comments. Prefer:
// Preserve Base<Env> so this remains legal:
// class Room extends MixedBase<Env> {}over:
// Type-level plumbing.Tests#
Add or update all relevant levels:
- Type tests:
mixins/*.type.test.tsusing VitestexpectTypeOf. - Unit tests: worker-pool Vitest tests using
cloudflare:test. - E2E tests: fronting Worker tests when runtime behavior depends on real Worker/DO/D1 behavior.
Type tests should read as examples:
- composed base can still be extended as
Base<Env>; - members from earlier mixins survive later mixins;
- env lower-bounds reject missing bindings;
- protected members are visible in subclasses and hidden from callers;
- helper options are required/optional exactly when intended.
- configured callbacks infer the intended init params, env lower-bound, and returned state shape;
- if a mixin requires lifecycle/core capabilities, applying it directly to
DurableObjectshould be a type error.
Run before handing off:
pnpm --dir packages/shared typecheck
pnpm --dir packages/shared test:durable-object-utils
pnpm exec oxlint packages/shared/src/durable-object-utils --threads 1 --deny-warningsFor production-runtime coverage:
cd packages/shared
doppler run --config dev_jonas -- pnpm test:durable-object-utils:e2e:deployPR Hygiene#
- Keep
packages/shared/src/durable-object-utils/AGENTS.mdsymlinked toREADME.md. - Do not add barrel files unless explicitly requested.
- Keep relative imports explicit with
.ts. - Avoid
as any; if a cast is unavoidable, keep it local and explain the exact runtime fact that makes it safe. - If a review comment says the type surface is unclear, add a focused example instead of adding more abstractions.