05 · Loan Origination (Everything Together)
A realistic, end-to-end workflow that uses nearly every feature, wired the way you'd wire it in a service — with a small "service layer" around the instance and a crash-and-restore in the middle of the happy path.
Workflow diagram
Features shown: typed context driving guards, Guard.and of an injected role + a payload check, fork/join (mode: 'all'), a wait state, a deadline (auto-withdraw), lifecycle hooks, snapshot persistence + restore with guard re-injection, rewind time-travel, and both exporters.
Code
ts
import { z } from 'zod';
import {
createWorkflow,
Guard,
type HookContext,
type HistoryEntry,
type WorkflowInstance,
} from 'flowyd';
import { MermaidExporter, JsonGraphExporter } from 'flowyd/visualization';ts
// ─── Context: the facts about the applicant that policy guards read ──────────────
const ApplicationSchema = z.object({
applicantName: z.string(),
requestedAmount: z.number().positive(),
creditScore: z.number().int().min(300).max(850),
annualIncome: z.number().positive(),
});
type Application = z.infer<typeof ApplicationSchema>;
// ─── Action payloads ────────────────────────────────────────────────────────────
const SubmitDocsSchema = z.object({ documentIds: z.array(z.string()).min(1) });
const CheckSchema = z.object({ officerId: z.string(), reference: z.string() });
const SendToUwSchema = z.object({ requestedBy: z.string() });
const DecideSchema = z.object({
underwriterId: z.string(),
verdict: z.enum(['approve', 'decline']),
notes: z.string().optional(),
});
type DecidePayload = z.infer<typeof DecideSchema>;
// ─── Reusable guards ─────────────────────────────────────────────────────────────
// Only a real underwriter may decide — resolved per instance via injectGuard.
const isUnderwriter = Guard.inject('isUnderwriter');
// Verdict-routing guards (mutually exclusive) composed with the role check.
const approves = Guard.and([
isUnderwriter,
Guard.fn<DecidePayload, Application>((ctx) => ctx.payload.verdict === 'approve'),
]);
const declines = Guard.and([
isUnderwriter,
Guard.fn<DecidePayload, Application>((ctx) => ctx.payload.verdict === 'decline'),
]);
// ─── Hooks: side effects (email, audit, paging) live here, not in the graph ─────
const log = (msg: string): void => console.log(` ↳ ${msg}`);
const onUnderwriting = (c: HookContext<Application>): void =>
log(`[hook] assigning underwriter for ${c.context.applicantName} ($${c.context.requestedAmount})`);
const onApproved = (c: HookContext<Application>): void =>
log(`[hook] sending approval letter to ${c.context.applicantName}`);
const onDeclined = (c: HookContext<Application>): void =>
log(`[hook] sending decline letter to ${c.context.applicantName}`);
// ─── Workflow definition ─────────────────────────────────────────────────────────
export const loan = createWorkflow({ name: 'loan-origination' })
.setContext(ApplicationSchema)
.defineAction('SUBMIT_DOCS', SubmitDocsSchema)
.defineAction('CREDIT_OK', CheckSchema)
.defineAction('INCOME_OK', CheckSchema)
.defineAction('FRAUD_OK', CheckSchema)
.defineAction('SEND_TO_UW', SendToUwSchema)
.defineAction('DECIDE', DecideSchema)
.addStep('received', { label: 'Application Received' })
// "cleared" steps registered before the join (auto-complete on entry).
.addStep('credit-cleared', { label: 'Credit Cleared' })
.addStep('income-cleared', { label: 'Income Cleared' })
.addStep('fraud-cleared', { label: 'Fraud Cleared' })
// "check" steps registered before the fork (the parallel branches).
.addStep('credit-check', { label: 'Credit Check' })
.addStep('income-check', { label: 'Income Check' })
.addStep('fraud-check', { label: 'Fraud Check' })
.addFork('run-checks', {
label: 'Run Checks',
targets: ['credit-check', 'income-check', 'fraud-check'],
})
.addJoin('checks-complete', {
label: 'All Checks Cleared',
requires: ['credit-cleared', 'income-cleared', 'fraud-cleared'],
mode: 'all',
})
.addWait('underwriting', {
label: 'Underwriting',
externalName: 'manual-underwriting',
onEnter: onUnderwriting,
})
.addStep('approved', { label: 'Approved', onEnter: onApproved })
.addStep('declined', { label: 'Declined', onEnter: onDeclined })
.addStep('withdrawn', { label: 'Withdrawn' })
.setInitial('received')
.setTerminal(['approved', 'declined', 'withdrawn'])
// Docs arrive → fan out to all three checks. Or, after 7 days of silence, the
// application auto-withdraws (a deadline arc — no `on`).
.addTransition({ from: 'received', to: 'run-checks', on: 'SUBMIT_DOCS' })
.addTransition({ from: 'received', to: 'withdrawn', after: '7d' })
// Each check clears only if the applicant's context satisfies policy.
.addTransition({
from: 'credit-check',
to: 'credit-cleared',
on: 'CREDIT_OK',
guard: (ctx) => ctx.context.creditScore >= 650,
})
.addTransition({
from: 'income-check',
to: 'income-cleared',
on: 'INCOME_OK',
// Loan must be at most 4× annual income.
guard: (ctx) => ctx.context.requestedAmount <= ctx.context.annualIncome * 4,
})
.addTransition({
from: 'fraud-check',
to: 'fraud-cleared',
on: 'FRAUD_OK',
guard: (ctx) => ctx.context.creditScore >= 600, // stand-in for a fraud model
})
.addTransition({ from: 'checks-complete', to: 'underwriting', on: 'SEND_TO_UW' })
// Manual decision: role-gated AND verdict-routed.
.addTransition({ from: 'underwriting', to: 'approved', on: 'DECIDE', guard: approves })
.addTransition({ from: 'underwriting', to: 'declined', on: 'DECIDE', guard: declines })
.build();
// ─── A tiny "service layer" around the workflow ─────────────────────────────────
//
// In a real app these would talk to your DB and auth. Guards are runtime
// behaviour, so they're injected on creation AND after every restore.
type LoanInstance = WorkflowInstance<
{
SUBMIT_DOCS: z.infer<typeof SubmitDocsSchema>;
CREDIT_OK: z.infer<typeof CheckSchema>;
INCOME_OK: z.infer<typeof CheckSchema>;
FRAUD_OK: z.infer<typeof CheckSchema>;
SEND_TO_UW: z.infer<typeof SendToUwSchema>;
DECIDE: DecidePayload;
},
Application
>;
const underwriterStaff = new Set(['uw-100', 'uw-205']);
/** Re-attach all runtime guards. Must run on create and after every restore. */
function injectGuards(instance: LoanInstance): LoanInstance {
return instance.injectGuard<DecidePayload>(
'isUnderwriter',
(ctx) => underwriterStaff.has(ctx.payload.underwriterId),
);
}
function openApplication(id: string, app: Application): LoanInstance {
return injectGuards(loan.createInstance(id, app));
}
/** Simulate persistence: snapshot → JSON string (what you'd store) and back. */
function persist(instance: LoanInstance): string {
return JSON.stringify(instance.getSnapshot());
}
function load(json: string): LoanInstance {
// Derive the exact snapshot type the definition expects (its state-ID union),
// rather than the wider `string`-keyed shape a bare JSON.parse would infer.
const snapshot = JSON.parse(json) as Parameters<typeof loan.restoreInstance>[0];
return injectGuards(loan.restoreInstance(snapshot));
}
// ─── One-line audit label for a history entry (narrowing the discriminated kind) ─
function historyLabel(h: HistoryEntry): string {
switch (h.kind) {
case 'action':
return h.action;
case 'timeout':
return `${h.from}→${h.to} (deadline)`;
case 'resolve-wait':
return `resolve:${h.stateId}`;
}
}
// ─── Run ─────────────────────────────────────────────────────────────────────────
async function run() {
console.log('=== Loan Origination — State Diagram ===\n');
console.log(MermaidExporter.export(loan.getDefinition()));
console.log('\n=== A. Happy path, with a crash-and-restore in the middle ===\n');
let app = openApplication('LN-001', {
applicantName: 'Jordan Reyes',
requestedAmount: 180_000,
creditScore: 742,
annualIncome: 95_000,
});
await app.dispatch('SUBMIT_DOCS', { documentIds: ['payslip.pdf', 'id.pdf', 'bank.pdf'] });
console.log(` docs in → parallel checks: ${app.getCurrentStates()}`);
await app.dispatch('CREDIT_OK', { officerId: 'co-1', reference: 'CR-9' });
await app.dispatch('INCOME_OK', { officerId: 'co-2', reference: 'IN-4' });
console.log(` 2 of 3 cleared : ${app.getCurrentStates()}`);
// ---- The server restarts here. Persist, then rebuild from JSON. ----
const stored = persist(app);
console.log(` [persisted ${stored.length} bytes to the "database"]`);
app = load(stored);
console.log(` [restored — re-injected guards]`);
await app.dispatch('FRAUD_OK', { officerId: 'co-3', reference: 'FR-2' });
// The join fired automatically the moment the third check cleared.
console.log(` 3rd cleared → join active : ${app.getCurrentStates()}`);
await app.dispatch('SEND_TO_UW', { requestedBy: 'co-1' });
console.log(` sent to underwriting : ${app.getCurrentStates()} (waiting on a human)`);
// A junior staffer can't decide; the guard blocks them.
const blocked = await app.dispatch('DECIDE', { underwriterId: 'intern-x', verdict: 'approve' });
if (!blocked.success) console.log(` intern tries to approve → blocked: ${blocked.reason}`);
// The underwriter finishes their manual review → resolve the wait, then decide.
app.resolveWait('underwriting');
await app.dispatch('DECIDE', {
underwriterId: 'uw-205',
verdict: 'approve',
notes: 'Strong file; LTV within policy.',
});
console.log(` underwriter approves : ${app.getCurrentStates()} terminal: ${app.isTerminal()}`);
// ── Audit trail + time-travel ────────────────────────────────────────────────
const snap = app.getSnapshot();
console.log(`\n Audit trail (${snap.history.length} entries):`);
snap.history.forEach((h, i) => console.log(` v${i + 1}: ${historyLabel(h)}`));
// `rewind` reconstructs the EXACT state at an earlier version, non-destructively.
const atUw = app.rewind(snap.history.findIndex((h) => historyLabel(h) === 'SEND_TO_UW') + 1);
const waiting = Object.entries(atUw.stateStatuses)
.filter(([, s]) => s === 'waiting')
.map(([id]) => id);
console.log(` rewind to v${atUw.version}: waiting on → ${waiting.join(', ') || '—'}`);
console.log(` (live instance is untouched: still ${app.getCurrentStates()})`);
// ── B. The deadline path: applicant never sends documents ─────────────────────
console.log('\n=== B. No documents for 7 days → auto-withdrawn ===\n');
const created = new Date('2026-06-01T00:00:00Z');
// Pass an explicit creation time so the 7-day deadline is deterministic.
const stale = injectGuards(
loan.createInstance(
'LN-002',
{ applicantName: 'Sam Carter', requestedAmount: 40_000, creditScore: 690, annualIncome: 60_000 },
created,
),
);
console.log(` created ${created.toISOString().slice(0, 10)} → ${stale.getCurrentStates()}`);
console.log(` withdraws at: ${stale.getNextDueAt()?.slice(0, 10)}`);
// A scheduler sweeps 8 days later and ticks the idle instance.
const fired = await stale.tick(new Date('2026-06-09T00:00:00Z'));
console.log(` tick(+8d) fired ${fired} deadline → ${stale.getCurrentStates()} terminal: ${stale.isTerminal()}`);
// ── JSON graph (e.g. to feed a dashboard renderer) ────────────────────────────
console.log('\n=== JSON graph summary (for dashboards / Cytoscape / d3) ===\n');
const graph = JsonGraphExporter.export(loan.getDefinition());
console.log(` nodes: ${graph.nodes.length}, edges: ${graph.edges.length}`);
console.log(` guarded transitions: ${graph.edges.filter((e) => e.hasGuard).length}`);
console.log(` timed transitions : ${graph.edges.filter((e) => e.after !== undefined).length}`);
}What to notice
- Guards are runtime behaviour, never serialised. The service helpers re-inject them on create and after every restore — this is the single most common production gotcha. The
load()helper types the parsed JSON asParameters<typeof loan.restoreInstance>[0]so the snapshot keeps the definition's exact state-ID union, not a barestring. - The join is automatic. The moment the third check clears,
checks-completeactivates with no extra dispatch. rewind(version)is non-destructive. It reconstructs an independent snapshot of any past version (with the context that was live then) while the running instance stays exactly where it is.- One definition, two views.
MermaidExporterandJsonGraphExporterboth read the compiled definition — diagrams and dashboards never drift from the source of truth.
Run it locally:
npx tsx examples/05-loan-origination.ts