Workflows and state machines
Complex workflows should stay app-owned. Beignet gives you typed use cases, events, jobs, schedules, idempotency, outbox delivery, policies, audit logs, and devtools so the workflow is explicit without forcing every app into a single state-machine runtime.
Use this pattern when a process has durable state, actor and tenant rules, side effects after commit, or retry behavior. Common examples include onboarding, approvals, appointment lifecycle, intake review, and document processing.
The rule
Keep the workflow state in your database and repositories. Keep each transition in a command use case. Use events and outbox records for facts that must trigger post-commit work.
features/
intake/
contracts.ts
routes.ts
policy.ts
ports.ts
domain/
intake.ts
events.ts
listeners/
jobs/
schedules/
tests/
use-cases/
submit-intake.ts
approve-intake.ts
reject-intake.ts
The folder shape is ordinary Beignet structure. The state machine is not a new framework layer; it is the feature's domain model plus transition use cases.
Model states in the domain
Use domain code for the allowed states and transitions. Keep this code free of infra, route handlers, React, and provider packages.
export const intakeStatuses = [
"draft",
"submitted",
"in_review",
"approved",
"rejected",
] as const;
export type IntakeStatus = (typeof intakeStatuses)[number];
export type Intake = {
id: string;
tenantId: string;
status: IntakeStatus;
version: number;
submittedAt: string | null;
reviewedAt: string | null;
};
export type ApproveIntakeTransitionResult =
| { ok: true; value: Intake & { reviewedAt: string } }
| { ok: false; reason: "version_conflict"; currentVersion: number }
| { ok: false; reason: "invalid_status"; status: IntakeStatus };
export function approveIntakeTransition(
intake: Intake,
options: { expectedVersion: number; now: Date },
): ApproveIntakeTransitionResult {
if (intake.version !== options.expectedVersion) {
return {
ok: false,
reason: "version_conflict",
currentVersion: intake.version,
};
}
if (intake.status !== "in_review") {
return { ok: false, reason: "invalid_status", status: intake.status };
}
return {
ok: true,
value: {
...intake,
status: "approved",
version: intake.version + 1,
reviewedAt: options.now.toISOString(),
},
};
}
The domain helper does not know about HTTP, route contracts, or provider details. The use case maps transition failures to the app error catalog.
Put transitions in use cases
A transition use case loads the current state, checks policy, applies the domain transition, writes the new state, audits the decision, and records events inside the same Unit of Work.
import { defineEvent } from "@beignet/core/events";
import { z } from "zod";
import { appError } from "@/features/shared/errors";
import { auditEntry } from "@/lib/audit";
import { useCase } from "@/lib/use-case";
import { approveIntakeTransition } from "../domain/intake";
export const IntakeApproved = defineEvent("intake.approved", {
payload: z.object({
intakeId: z.string().uuid(),
approvedAt: z.string().datetime(),
}),
});
export const approveIntake = useCase
.command("intake.approve")
.input(
z.object({
intakeId: z.string().uuid(),
expectedVersion: z.number().int().positive(),
}),
)
.emits([IntakeApproved])
.run(async ({ ctx, input, events }) => {
return ctx.ports.uow.transaction(async (tx) => {
const intake = await tx.intakes.findById({
id: input.intakeId,
tenantId: ctx.tenant.id,
});
if (!intake) {
throw appError("IntakeNotFound", {
details: { intakeId: input.intakeId },
});
}
await ctx.gate.authorize("intake.approve", intake);
const approved = approveIntakeTransition(intake, {
expectedVersion: input.expectedVersion,
now: new Date(),
});
if (!approved.ok) {
throw appError(
approved.reason === "version_conflict"
? "IntakeVersionConflict"
: "IntakeInvalidStatus",
{ details: approved },
);
}
await tx.intakes.update(approved.value);
await tx.audit.record(
auditEntry(ctx, {
action: "intake.approve",
resource: { type: "intake", id: intake.id },
}),
);
await events.record(tx.events, IntakeApproved, {
intakeId: approved.value.id,
approvedAt: approved.value.reviewedAt,
});
return approved.value;
});
});
The use case stays callable from HTTP routes, jobs, scheduled tasks, scripts, and tests. That keeps the workflow rule in one place.
Run side effects after commit
For reliable side effects, record an event through transaction-scoped
tx.events. Configure the production Unit of Work to use the outbox event
recorder. Then listeners can enqueue jobs, send notifications, update search
indexes, or start downstream processing after the database commit succeeds.
import { createEventHandlers } from "@beignet/core/events";
import type { AppContext } from "@/app-context";
const listeners = createEventHandlers<AppContext>();
export const createReviewPacket = listeners.defineListener(IntakeApproved, {
name: "intake.create-review-packet",
async handle({ payload, ctx }) {
await ctx.ports.jobs.dispatch(CreateReviewPacketJob, {
intakeId: payload.intakeId,
});
},
});
The listener is intentionally small. Durable retry, backoff, and dead-letter behavior belongs to the job/outbox path, not to the transition use case.
Use schedules for stale states
Schedules are useful when the workflow needs time-based nudges or recovery: expire abandoned onboarding sessions, remind patients before appointments, re-open stuck document reviews, or escalate approvals that have not moved.
import { createScheduleHandlers } from "@beignet/core/schedules";
import { z } from "zod";
import type { AppContext } from "@/app-context";
const schedules = createScheduleHandlers<AppContext>();
export const escalateStaleIntakeReviews = schedules.defineSchedule(
"intake.escalate-stale-reviews",
{
cron: "*/15 * * * *",
payload: z.object({}),
createPayload() {
return {};
},
async handle({ ctx }) {
const stale = await ctx.ports.intakes.findStaleReviews();
for (const intake of stale) {
await ctx.ports.jobs.dispatch(EscalateIntakeReviewJob, {
intakeId: intake.id,
});
}
},
},
);
Keep long-running work out of the schedule handler. Dispatch jobs or write outbox records so retry behavior remains inspectable.
Add idempotency at entry points
Use idempotency for commands that can be retried by browsers, webhooks, mobile clients, or external systems. Put the key around the command use case so the workflow result, conflict behavior, and audit path stay consistent.
Good candidates:
- Appointment booking and rescheduling
- Intake submission
- Approval decisions triggered by a webhook
- Document upload completion
- Payment or billing actions
Do not use idempotency as a replacement for optimistic concurrency. Keep
expectedVersion or an equivalent repository guard on state transitions so two
actors cannot silently overwrite each other.
Pattern examples
| Workflow | Durable state | Transitions | Post-commit work |
|---|---|---|---|
| Onboarding | invited, profile_started, verified, completed | Accept invite, verify email, complete profile | Send welcome mail, create default resources, schedule follow-up |
| Approval | draft, submitted, approved, rejected, changes_requested | Submit, approve, reject, request changes | Notify requester, write audit trail, update reports |
| Appointment lifecycle | requested, confirmed, checked_in, completed, cancelled, no_show | Request, confirm, check in, complete, cancel | Send reminders, release slots, sync calendar, bill visit |
| Intake review | draft, submitted, in_review, approved, rejected | Submit, assign, approve, reject | Create review packets, notify reviewer, escalate stale reviews |
| Document processing | uploaded, queued, processing, ready, failed, quarantined | Complete upload, start scan, mark ready, quarantine | Virus scan, extract text, generate preview, notify owner |
Testing workflow paths
Test each transition at the use-case boundary. Then add focused tests for the durable chain:
- The use case records the expected event inside the transaction.
- The outbox drains the event after commit.
- The listener enqueues the expected job or notification.
- Failed job delivery retries with the expected delay.
- Exhausted retries become dead-lettered and show useful instrumentation.
Use @beignet/core/ports/testing helpers such as
createUseCaseTester(...), createMemoryOutbox(...),
assertOutboxPending(...), assertOutboxRetryScheduled(...), and
assertOutboxDeadLettered(...) to keep tests readable without widening
production ports.
Choosing the smallest tool
Do not turn every multi-step operation into a formal state machine. Start with a use case and a status field. Add events when other parts of the app need to react. Add jobs when work should leave the request. Add outbox when the post-commit work must not be lost. Add schedules when time should start or repair workflow work.
See Workflow primitives for the decision table and Outbox for durable event and job delivery.