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:

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

WorkflowDurable stateTransitionsPost-commit work
Onboardinginvited, profile_started, verified, completedAccept invite, verify email, complete profileSend welcome mail, create default resources, schedule follow-up
Approvaldraft, submitted, approved, rejected, changes_requestedSubmit, approve, reject, request changesNotify requester, write audit trail, update reports
Appointment lifecyclerequested, confirmed, checked_in, completed, cancelled, no_showRequest, confirm, check in, complete, cancelSend reminders, release slots, sync calendar, bill visit
Intake reviewdraft, submitted, in_review, approved, rejectedSubmit, assign, approve, rejectCreate review packets, notify reviewer, escalate stale reviews
Document processinguploaded, queued, processing, ready, failed, quarantinedComplete upload, start scan, mark ready, quarantineVirus 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:

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.