Skip to main content

Why adapters exist

Workflow and job code should not care whether it runs on a Cloudflare Durable Object or in a test process. Adapters solve this by providing a stable interface that the execution engine depends on. Swap the implementation and the same workflow code runs unchanged.
AdapterPurposeProductionTesting
StorageAdapterKey-value persistenceDurable Object storage APIIn-memory Map
SchedulerAdapterAlarm schedulingDurable Object alarm APISimulated timer
RuntimeAdapterInstance identity and timeDO state.id / Date.now()Configurable values

The three adapter interfaces

All three are Effect services defined with Context.Tag. They are the only infrastructure dependencies the workflow engine has.

StorageAdapter

Provides durable key-value persistence.
class StorageAdapter extends Context.Tag("StorageAdapter")<
  StorageAdapter,
  {
    get<T>(key: string): Effect<T | undefined, StorageError>
    put<T>(key: string, value: T): Effect<void, StorageError>
    delete(key: string): Effect<void, StorageError>
    deleteAll(): Effect<void, StorageError>
  }
>() {}
The workflow engine uses this to persist step results, workflow state, and recovery metadata. Every Workflow.step() result is written here before the step’s value is returned to the caller.

SchedulerAdapter

Manages the single alarm associated with a workflow instance.
class SchedulerAdapter extends Context.Tag("SchedulerAdapter")<
  SchedulerAdapter,
  {
    schedule(time: number): Effect<void, SchedulerError>
    cancel(): Effect<void, SchedulerError>
    getScheduled(): Effect<number | undefined, SchedulerError>
  }
>() {}
When a workflow pauses (sleep or retry delay), the orchestrator calls schedule(resumeAt). When it completes, it calls cancel() to clear any pending alarm.

RuntimeAdapter

Provides identity and time for a workflow instance.
class RuntimeAdapter extends Context.Tag("RuntimeAdapter")<
  RuntimeAdapter,
  {
    readonly instanceId: string
    now(): Effect<number>
  }
>() {}
instanceId is the stable identifier for the Durable Object instance. now() returns the current time in milliseconds and is used for all internal timestamp calculations, making it controllable in tests.

Cloudflare DO adapters

These are the production implementations, backed by the Durable Object’s native storage and alarm APIs.
import {
  createDOStorageAdapter,
  createDOSchedulerAdapter,
  createDurableObjectRuntime,
} from "@durable-effect/workflow";
createDurableObjectRuntime(state) composes all three adapters into a single RuntimeLayer using the DO’s DurableObjectState. This is what createDurableWorkflows() uses internally — you do not need to call these functions directly when using the engine factory.

In-memory adapters for testing

The in-memory adapters let you run workflows in a plain Node.js or Vitest environment with no Durable Objects involved.
import { createInMemoryRuntime } from "@durable-effect/workflow";
createInMemoryRuntime returns a layer and a handle. The layer provides all three adapters. The handle gives you control over simulated time and lets you inspect internal state.
const { layer, handle } = await Effect.runPromise(
  createInMemoryRuntime({ initialTime: 1000 })
);

TestRuntimeHandle

interface TestRuntimeHandle {
  // Inspect state
  getStorageState(): Effect<InMemoryStorageState>
  getSchedulerState(): Effect<InMemorySchedulerState>
  getCurrentTime(): Effect<number>

  // Control time
  advanceTime(ms: number): Effect<void>
  setTime(time: number): Effect<void>

  // Control alarms
  shouldAlarmFire(): Effect<boolean>
  clearAlarm(): Effect<void>
}
import { Effect } from "effect";
import { createInMemoryRuntime } from "@durable-effect/workflow";

const { layer, handle } = await Effect.runPromise(
  createInMemoryRuntime({ initialTime: Date.now() })
);

// Run your workflow under the in-memory layer
// ...

// Advance simulated time past a sleep duration
await Effect.runPromise(handle.advanceTime(60_000));

// Check whether an alarm is due
const shouldFire = await Effect.runPromise(handle.shouldAlarmFire());

// Inspect what is stored in DO storage
const storageState = await Effect.runPromise(handle.getStorageState());
createInMemoryRuntime is re-exported from @durable-effect/core as createInMemoryRuntime alongside lower-level helpers createInMemoryStorage and createInMemoryScheduler if you need to compose adapters individually.

Effect service pattern

Adapters are pure Effect services. They are injected into the rest of the engine via layers, and nothing in the engine imports them directly. This means any implementation that satisfies the interface works — you can write a custom adapter for a different storage backend without changing any workflow code.

Layer composition

The engine assembles all layers bottom-up. Adapters sit at the foundation:
┌─────────────────────────────────────────────────────────────┐
│                  WorkflowOrchestratorLayer                  │
│  Coordinates lifecycle, emits events, handles alarms        │
└─────────────────────────────────────────────────────────────┘

        ┌─────────────────────┼─────────────────────┐
        ▼                     ▼                     ▼
┌───────────────┐    ┌────────────────┐    ┌───────────────┐
│WorkflowExecutor│   │  TrackerLayer  │    │RegistryLayer  │
│    Layer      │    │ (HTTP/Noop)    │    │               │
└───────────────┘    └────────────────┘    └───────────────┘


┌─────────────────────────────────────────────────────────────┐
│              WorkflowStateMachineLayer                      │
│  State transitions, step tracking, recovery state           │
└─────────────────────────────────────────────────────────────┘


┌─────────────────────────────────────────────────────────────┐
│                      RuntimeLayer                           │
│  StorageAdapter + SchedulerAdapter + RuntimeAdapter         │
│  (Durable Objects or In-Memory)                             │
└─────────────────────────────────────────────────────────────┘
The createDurableWorkflows() factory assembles this stack internally:
const orchestratorLayer = WorkflowOrchestratorLayer<W>().pipe(
  Layer.provideMerge(WorkflowRegistryLayer(workflows)),
  Layer.provideMerge(WorkflowExecutorLayer),
  Layer.provideMerge(RecoveryManagerLayer(options?.recovery)),
  Layer.provideMerge(WorkflowStateMachineLayer),
  Layer.provideMerge(purgeLayer),
  Layer.provideMerge(trackerLayer),
  Layer.provideMerge(runtimeLayer),   // createDurableObjectRuntime(state)
);
In tests you swap runtimeLayer for the output of createInMemoryRuntime and the rest of the stack is identical to production.

Build docs developers (and LLMs) love