Workflows

Beignet splits durable application work into small primitives: events, jobs, schedules, tasks, notifications, idempotency keys, and outbox records. This page is the map for that toolbox: which primitive answers which question, how side effects stay transactional, and how the pieces compose into multi-step workflows with durable state.

Workflow primitives

Pick the primitive that matches the sentence your code is trying to say:

PrimitiveUse when the code saysOwnsDoes not own
Command use case"Do this business operation now."Input/output validation, transaction boundaries, business decisions, audit, domain eventsHTTP parsing, durable delivery, vendor-specific side effects
Event"This fact happened."Stable fact name, payload schema, fan-out to listenersWork ordering, retry policy, user communication intent
Job"Do this work later or outside the request."One handler, payload schema, retry policy, background executionDatabase commit atomicity unless dispatched through outbox
Schedule"Start this workflow at this time."Cron expression, time metadata, trigger payloadDurable retry/dead-letter semantics in core
Notification"Tell a person or team about this."Communication intent, channel rendering, channel selectionDurable execution by itself
Idempotency key"This logical command may arrive again."Duplicate detection, payload fingerprinting, safe replay/conflict handlingBackground delivery or side-effect scheduling
Outbox record"This event or job must commit with the database write."Transactional side-effect intent, retry, backoff, dead-letter stateIdempotency inside the eventual listener or job

Tasks are the eighth primitive: operational entrypoints you run on demand, such as backfills and maintenance work, through beignet task run.

Common combinations:

When in doubt, keep the command use case as the business boundary. Add events for facts other parts of the app may care about, jobs for explicit background work, and outbox only when losing the post-commit side effect is unacceptable.

Side effects after commit

Every durable primitive shares one rule: do not perform side effects inside an open database transaction. Use cases record intent inside the transaction — events.record(tx.events, ...) for facts, a transaction-scoped dispatcher for jobs. If the transaction rolls back, the recorded intent is discarded, so listeners, jobs, and mail never observe data that did not commit. If it commits, the Unit of Work validates and publishes the buffered events — or, in production, the outbox stores them as database rows in the same transaction and a worker drains them after commit with retries.

Background delivery is at least once, not exactly once. Put idempotency inside listeners and job handlers that own work that must not happen twice.

See Database and transactions for the Unit of Work wiring that backs tx.events.

App-bound builders

Every workflow capability follows the same definition pattern: create the app-bound builder once in lib/<capability>.ts with the matching factory so each definition's ctx is typed as your AppContext:

// lib/jobs.ts
import { createJobs } from "@beignet/core/jobs";
import type { AppContext } from "@/app-context";

export const { defineJob } = createJobs<AppContext>();

createListeners, createNotifications, createSchedules, and createTasks follow the same shape in lib/listeners.ts, lib/notifications.ts, lib/schedules.ts, and lib/tasks.ts. New apps do not ship these files; the beignet make generators create each one on first use.

Service contexts

Background work has no request to build a context from. Registry modules such as server/schedules.ts and server/outbox.ts call server.createServiceContext(...), which builds an AppContext through the service factory declared in the server's context blueprint and attaches ctx.gate the same way it does for requests. beignet schedule run, beignet task run, beignet outbox drain, and the cron route helpers all run through it. See Routes and server for the blueprint.

Workflows as state machines

Multi-step processes — onboarding, approvals, appointment lifecycles, intake review — should stay app-owned. Keep the workflow state in your database and repositories, and keep each transition in a command use case. The state machine is not a new framework layer; it is the feature's domain model plus transition use cases in the ordinary feature folder shape.

Model the allowed states and transitions in domain code, free of infra, route handlers, React, and provider packages: a status union such as "draft" | "submitted" | "in_review" | "approved", a version field for optimistic concurrency, and pure transition functions that return either the next state or a typed failure reason.

A transition use case loads the current state, checks policy, applies the domain transition, maps failures to the app error catalog, writes the new state, audits the decision, and records events inside the same Unit of Work:

import { z } from "zod";
import { appError } from "@/features/shared/errors";
import { auditEntry } from "@/lib/audit";
import { useCase } from "@/lib/use-case";
import { IntakeApproved } from "../domain/events";
import { approveIntakeTransition } from "../domain/intake";

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");
      }

      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, schedules, scripts, and tests, so the workflow rule lives in one place. A listener on IntakeApproved then dispatches follow-up jobs or notifications after commit.

Time-based recovery

Use schedules when a workflow needs time-based nudges or recovery: expire abandoned onboarding sessions, remind patients before appointments, or escalate approvals that have not moved. Keep long-running work out of the schedule handler — dispatch jobs or write outbox records so retry behavior stays inspectable.

Idempotency at entry points

Use idempotency for commands that browsers, webhooks, mobile clients, or external systems may retry: declare meta.idempotency on HTTP contracts, and wrap non-HTTP entry points in runIdempotently(...). Do not use it as a replacement for optimistic concurrency — keep expectedVersion or an equivalent repository guard on state transitions so two actors cannot silently overwrite each other.

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 it after commit, the listener enqueues the expected job, and exhausted retries dead-letter with useful instrumentation. The @beignet/core/ports/testing helpers such as createUseCaseTester(...) and assertOutboxDeadLettered(...) keep these 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, jobs when work should leave the request, outbox when the post-commit work must not be lost, and schedules when time should start or repair workflow work.