cloudflare stack
why
platform entrypoints are discovery surface, not business owners. once a Cloudflare workflow or durable object shell absorbs domain logic, that logic can only be exercised by deploying the runtime, and every platform concern change (retries, idempotency, state transitions) becomes a domain edit. a thin shell keeps business behavior testable with ordinary unit tests regardless of runtime.
rule
apply framework entrypoints
to Cloudflare-discovered declarations. use a leading ~ folder prefix for
non-business entrypoints at a source root: ~workflows/, ~durable/,
~agents/, ~containers/ — the prefix makes exceptional runtime surface
visible without polluting business domain folders.
keep the shell thin: validate input, establish context, delegate to a named domain owner returning a command or result, then shape the platform response. anything more hides platform concerns (retries, idempotency, state transitions) inside the shell and forces a full runtime deploy to test or debug.
resource families
| folder | use it for | keep inside |
|---|---|---|
~workflows/ | one Cloudflare WorkflowEntrypoint or Think workflow class per namesake file. | workflow shell, platform input, delegation. |
~durable/ | one DurableObject or container-facing class per namesake file. | stateful coordination, rpc/websocket boundary, delegation. |
~agents/ | one Think or Agents SDK resource class per namesake file. | agent entrypoint, callable boundary, delegation. |
~containers/ | a self-contained Cloudflare container build context. | Dockerfile and that container's isolated Node runtime source. |
ordinary bindings — D1, KV, R2, Vectorize, queues, environment values — get no
~ folder; their runtime access belongs in the relevant domain or
responsibility owner.
placement decision
the prefix groups files Cloudflare discovers or invokes directly; it does not mean backend, shared, or non-business logic.
| candidate file | placement |
|---|---|
| class registered directly with a workflow, Agent, or Durable Object runtime | matching ~ family; thin shell only. |
| provider client, database repository, mapper, or domain service | normal responsibility/domain owner. |
| route handler that validates then delegates | framework-required route location, not a ~ family by default. |
| standalone task with no Cloudflare discovery contract | ordinary domain or technical folder. |
entrypoint flow
- Cloudflare invokes the thin prefixed entrypoint.
- the entrypoint establishes execution context and validates platform-shaped input.
- it converts that input to a typed domain command or reference.
- a domain, service, or adapter owns business behavior and durable writes.
- the entrypoint returns the platform-shaped result or schedules the next durable operation.
verification
binding, entrypoint class, file name, and folder family must name the same resource. exercise the actual platform boundary, then inspect the shell for repository, provider, or domain policy — those belong outside the prefixed family.
good and bad
bad
// ~workflows/runProcess.workflow.tsexport const runProcessWorkflow = Workflow.define("runProcess", async (input) => {const process = await db.query(`SELECT * FROM processes WHERE id = ?`, [input.processId]);if (!process) {throw new Error("Not found");}// repository queries, provider calls, retry policy all embeddedconst result = await provider.runWithRetry(process.name, process.config);if (result.failed) {await db.update("processes", { status: "failed", error: result.message });}return result;});
business logic and durable writes are embedded in the platform shell: testing retry policy or error handling requires a deployed workflow runtime, and a schema move becomes a two-step change (migrate, then update the embedded query string).
good
// ~workflows/runProcess.workflow.tsexport const runProcessWorkflow = Workflow.define("runProcess", async (input) => {const command = await validateAndBuildRunCommand(input);const result = await runProcessService.execute(command);return mapResultForWorkflowResponse(result);});// domains/process/runProcess.tsexport async function validateAndBuildRunCommand(input: unknown) {const validated = RunProcessInputSchema.parse(input);const process = await processRepo.readRequired(validated.processId);return { processId: process.id, name: process.name, config: process.config };}export async function execute(command: RunProcessCommand) {const result = await providerClient.runWithRetry(command.name, command.config);if (result.failed) {await processRepo.updateStatus(command.processId, {status: "failed",error: result.message,});}return result;}
the workflow shell adapts platform input, builds a domain command, and delegates to an ordinary service: business logic is testable outside the workflow runtime, and schema changes touch only the persistence layer.