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:
| Primitive | Use when the code says | Owns | Does not own |
|---|---|---|---|
| Command use case | "Do this business operation now." | Input/output validation, transaction boundaries, business decisions, audit, domain events | HTTP parsing, durable delivery, vendor-specific side effects |
| Event | "This fact happened." | Stable fact name, payload schema, fan-out to listeners | Work ordering, retry policy, user communication intent |
| Job | "Do this work later or outside the request." | One handler, payload schema, retry policy, background execution | Database commit atomicity unless dispatched through outbox |
| Schedule | "Start this workflow at this time." | Cron expression, time metadata, trigger payload | Durable retry/dead-letter semantics in core |
| Notification | "Tell a person or team about this." | Communication intent, channel rendering, channel selection | Durable execution by itself |
| Idempotency key | "This logical command may arrive again." | Duplicate detection, payload fingerprinting, safe replay/conflict handling | Background 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 state | Idempotency 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:
- Reliable side effect after commit: the use case records an event through a transaction-scoped outbox recorder, the outbox drains after commit, and a listener dispatches a job or sends a notification.
- Scheduled durable work: the schedule handler computes the run payload and dispatches a job instead of doing long-running work inside the cron trigger.
- Retry-safe external call: the job handler wraps the provider call in
runIdempotently(...)when the provider or worker may retry the same logical work. - Payment fulfillment: the webhook route verifies the provider event, the billing use case keys idempotency on the event ID, and committed entitlement changes emit events or jobs through the outbox. See Payments and billing.
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.