The two hooks
Every transitionTo call passes through two optional hook points:
- • Guards run before the transition is written. They can reject it (throws
GuardRejected). Use them to enforce domain rules that can’t be expressed in the state graph alone. - • Callbacks run after the transition writes but inside the same transaction. Use them to keep related tables in sync, enqueue jobs, or emit domain events.
Guards
A guard is a function called with a GuardContext. It returns true (or nothing) to allow the transition, or throws / returns false to block it.
typescript
import { defineMachine, GuardRejected } from "@stateledger/core";
const invoiceMachine = defineMachine({
name: "invoice",
states: ["draft", "sent", "paid", "cancelled"],
initialState: "draft",
transitions: [
{ from: "draft", to: "sent" },
{ from: "sent", to: "paid" },
] as const,
guards: {
"sent->paid": async ({ subject, from, to }) => {
// Only pay invoices with a positive amount
const invoice = subject as { total: number };
if (invoice.total <= 0) {
throw new GuardRejected(
"invoice",
"inv_0142",
from,
to,
"Invoice total must be > 0 to mark paid",
);
}
},
},
});Guards receive the subject you passed at .for() time so they can inspect current business fields, plus the from and to state.
Callbacks
Callbacks fire after the transition row is written but before the enclosing transaction commits. If a callback throws, the transition rolls back with the rest of the transaction | so callback code is safe to fail.
typescript
import { defineMachine } from "@stateledger/core";
import { enqueue } from "@stateledger/outbox";
const invoiceMachine = defineMachine({
name: "invoice",
states: ["draft", "sent", "paid"],
initialState: "draft",
transitions: [
{ from: "draft", to: "sent" },
{ from: "sent", to: "paid" },
] as const,
callbacks: {
"sent->paid": async ({ tx, subjectId, from, to, metadata }) => {
// Update the business row in the SAME transaction:
await tx.invoice.update({
where: { id: subjectId },
data: { paidAt: new Date() },
});
// Enqueue a side effect via @stateledger/outbox | ships in the
// same tx, delivered by a worker later (retries + DLQ built in).
await enqueue(tx, {
kind: "invoice.paid",
payload: { subjectId, metadata },
});
},
},
});Callback context
Callback fields available on the CallbackContext:
- •
tx— the active adapter transaction handle. Use this for downstream writes. - •
subject— the subject you passed at.for()time. - •
subjectId— the id string. - •
from,to— state literals. - •
actor— the actor recorded for this transition. - •
metadata— the metadata object passed totransitionTo.
Common patterns
- • Rollback-safe side effects. Update related tables inside the callback so they commit atomically with the transition.
- • Outbox pattern. Use
@stateledger/outbox’senqueue(tx, …)to write an intent inside the callback’s transaction. A separatecreateWorker()process delivers it at-least-once with backoff and a dead-letter queue. See the outbox docs for the full flow. - • Domain rule enforcement. Use guards for “you can only mark paid if the amount is settled” type rules that can’t be modeled by state transitions alone.
Never make network calls (email, HTTP, external APIs) inside a callback. They hold the advisory lock and transaction open. Enqueue via
@stateledger/outbox and let a worker deliver — see the outbox guide.