workflow-ts-architecture
Workflow-ts Architecture
Goal
Build deterministic application flows in this repository with workflow-ts primitives and documented project conventions.
Quick start
- Add
@workflow-ts/corefor workflow runtime logic. - Add
@workflow-ts/reactwhen a React screen should subscribe to rendering. - Model
StateandRenderingas explicit discriminated unions. - Keep actions pure and route side effects through workers.
- Validate behavior with runtime-level tests before UI tests.
Canonical references
Read references/doc-map.md first, then load only the specific guide needed for the task.
How to build a basic workflow
Use this template and customize:
import { createWorker, type Worker, type Workflow } from '@workflow-ts/core';
interface Props {
userId: string;
}
type State =
| { type: 'loading' }
| { type: 'loaded'; name: string }
| { type: 'error'; message: string };
type Output = { type: 'closed' };
type Rendering =
| { type: 'loading'; close: () => void }
| { type: 'loaded'; name: string; reload: () => void; close: () => void }
| { type: 'error'; message: string; retry: () => void; close: () => void };
type LoadResult = { ok: true; name: string } | { ok: false; message: string };
interface WorkerProvider {
loadProfileWorker: () => Worker<LoadResult>;
}
const defaultWorkerProvider: WorkerProvider = {
loadProfileWorker: () =>
createWorker<LoadResult>('load-profile', async (signal) => {
// Cooperate with cancellation via AbortSignal.
if (signal.aborted) return { ok: false, message: 'Cancelled' };
return { ok: true as const, name: 'Ada' };
}),
};
export function createProfileWorkflow(
workerProvider: WorkerProvider,
): Workflow<Props, State, Output, Rendering> {
return {
initialState: () => ({ type: 'loading' }),
render: (_props, state, ctx) => {
switch (state.type) {
case 'loading':
ctx.runWorker(workerProvider.loadProfileWorker(), 'profile-load', (result) => () => ({
state: result.ok
? { type: 'loaded', name: result.name }
: { type: 'error', message: result.message },
}));
return {
type: 'loading',
close: () => ctx.actionSink.send((s) => ({ state: s, output: { type: 'closed' } })),
};
case 'loaded':
return {
type: 'loaded',
name: state.name,
reload: () => ctx.actionSink.send(() => ({ state: { type: 'loading' } })),
close: () => ctx.actionSink.send((s) => ({ state: s, output: { type: 'closed' } })),
};
case 'error':
return {
type: 'error',
message: state.message,
retry: () => ctx.actionSink.send(() => ({ state: { type: 'loading' } })),
close: () => ctx.actionSink.send((s) => ({ state: s, output: { type: 'closed' } })),
};
}
},
};
}
export const profileWorkflow = createProfileWorkflow(defaultWorkerProvider);
DOkeep state transitions explicit with discriminated unions.DOmodel expected business failures as output data from workers.DOinject workers via aWorkerProviderinterface for deterministic tests.DO NOTmutate state; always return new state.DO NOTrely on thrown worker errors for domain transitions.
How to integrate with React
Use useWorkflow as a single subscription point and map renderings to components:
import { useWorkflow } from '@workflow-ts/react';
import type { JSX } from 'react';
export function ProfileScreen({ userId }: { userId: string }): JSX.Element {
const rendering = useWorkflow(profileWorkflow, { userId });
switch (rendering.type) {
case 'loading':
return <button onClick={rendering.close}>Close</button>;
case 'loaded':
return (
<>
<h1>{rendering.name}</h1>
<button onClick={rendering.reload}>Reload</button>
<button onClick={rendering.close}>Close</button>
</>
);
case 'error':
return (
<>
<p>{rendering.message}</p>
<button onClick={rendering.retry}>Retry</button>
<button onClick={rendering.close}>Close</button>
</>
);
}
}
DOkeeppropssmall and immutable.DOpass only minimal derived inputs touseWorkflow.DO NOTuse workflow-ts as a selector-based global store by default.DO NOTcallupdatePropswith identical references to force refreshes.
How to compose child workflows
Use stable keys and explicit output mapping:
const childRendering = ctx.renderChild(childWorkflow, childProps, 'child-key', (childOutput) => (state) => ({
state: handleChildOutput(state, childOutput),
}));
DOuse stable child keys for long-lived child state.DOmap child output into parent actions/state transitions.DO NOTchange keys unless creating a new child instance intentionally.
How to run worker-based async flows
Run workers only from render, key them by logical effect identity, and inject workers through a provider interface for testability.
Inside render, structure logic as:
- optional pre-
switchworker startup only when it must run regardless of state switch (state.type)as the main rendering/state-handling structure
import type { RenderContext, Worker } from '@workflow-ts/core';
interface WorkerProvider {
loadProfileWorker: () => Worker<LoadResult>;
}
function render(_props: Props, state: State, ctx: RenderContext<State, Output>): Rendering {
// Pre-switch work is only for workers that must run regardless of state.
// ctx.runWorker(workerProvider.auditWorker(), 'audit', () => (s) => ({ state: s }));
switch (state.type) {
case 'loading':
ctx.runWorker(workerProvider.loadProfileWorker(), 'profile-load', (result) => () => ({
state: result.ok
? { type: 'loaded', name: result.name }
: { type: 'error', message: result.message },
}));
return {
type: 'loading',
close: () => ctx.actionSink.send((s) => ({ state: s, output: { type: 'closed' } })),
};
case 'loaded':
return {
type: 'loaded',
name: state.name,
reload: () => ctx.actionSink.send(() => ({ state: { type: 'loading' } })),
close: () => ctx.actionSink.send((s) => ({ state: s, output: { type: 'closed' } })),
};
case 'error':
return {
type: 'error',
message: state.message,
retry: () => ctx.actionSink.send(() => ({ state: { type: 'loading' } })),
close: () => ctx.actionSink.send((s) => ({ state: s, output: { type: 'closed' } })),
};
}
}
- Same key + running worker keeps the worker alive and updates handlers.
- Missing key in the next render cancels the worker at end of render cycle.
- Disposing runtime cancels all active workers.
DOdefine worker dependencies behind aWorkerProviderinterface and inject it into workflow factories.DOkeeprenderprimarily asswitch (state.type)branches for clarity and exhaustiveness.DO NOTplace general branching/return logic before theswitch; reserve pre-switchcode for unconditional worker startup only.
How to write tests first
Prefer runtime-level tests in @workflow-ts/core for behavior:
import { createRuntime } from '@workflow-ts/core';
import { expect, it } from 'vitest';
it('transitions loading -> loaded', () => {
const runtime = createRuntime(profileWorkflow, { userId: 'u1' });
expect(runtime.getRendering().type).toBe('loading');
runtime.send(() => ({ state: { type: 'loaded', name: 'Ada' } }));
expect(runtime.getRendering().type).toBe('loaded');
runtime.dispose();
});
- Prefer provider-based worker stubs for deterministic retry/failure tests:
import { createWorker } from '@workflow-ts/core';
import { vi } from 'vitest';
const workerProvider: WorkerProvider = {
loadProfileWorker: vi
.fn()
.mockReturnValueOnce(createWorker('load-profile-fail', async () => ({ ok: false, message: 'TEST' })))
.mockReturnValueOnce(createWorker('load-profile-ok', async () => ({ ok: true, name: 'Ada' }))),
};
DOtest initial state/rendering, callbacks, outputs, props updates, and worker behavior.DOdispose runtime in every test.DOtest worker cancellation and retry paths deterministically.DOuseWorkerProviderto stub sequential worker outcomes (for example fail then success on retry).DO NOTrely on timing sleeps when a deferred completion pattern can be used.
How to persist and restore snapshots
Define snapshot and hydrate via initialState(props, snapshot):
const runtime = createRuntime(workflow, props, { snapshot: savedSnapshot });
const nextSnapshot = runtime.snapshot();
Call runtime.snapshot() at lifecycle checkpoints (for example app backgrounding) and restore it on next runtime creation.
How to add diagnostics
Use runtime interceptors for cross-cutting side effects and DevTools for event timelines:
const runtime = createRuntime(workflow, props, {
interceptors: [loggingInterceptor({ prefix: '[workflow]' })],
devTools: createDevTools(),
});
DOkeep action functions pure.DOuse interceptors for analytics/logging/metrics.DOwrap actions withnamed(...)when stable action names are needed.