defineActor
defineActor declares a state machine bound to a single domain entity. DDD readers: this is the aggregate root.
Two forms — schema-bound (recommended) and classic positional.
Schema-bound form (recommended)
Define data + lifecycle once via defineSchema, then bind transitions per state.
import { defineActor } from "@nwire/forge"
import { SubmissionData } from "./submission.schema"
export const Submission = defineActor({
schema: SubmissionData, // brings name, key, initial, final states
states: {
submitted: {
on: {
[AnswerWasFlagged.name]: {
target: "under-review",
assign: (_s, e) => ({ confidence: e.confidence }),
},
},
},
"under-review": {
on: { [SubmissionWasManuallyGraded.name]: { target: "graded" } },
after: { reminder: { delay: "3d", action: "submissions.send-reminder" } },
},
// `graded` is `final` in the schema — omit it.
},
methods: { /* ... */ },
})The actor inherits name, key, initial, and the set of valid states from the schema. Typos are caught at boot:
on:reactions on afinalstate → error- Transitions for an unknown state → error
Signature (schema-bound)
function defineActor<TFields, TMethods>(
options: ActorOptionsBound<TFields, TMethods>,
): ActorDefinition
interface ActorOptionsBound<TFields, TMethods> {
name?: string // defaults to schema.name
schema: SchemaDefinition<TFields>
states?: Record<string, { on?: ...; after?: ... }>
methods?: TMethods
stuckThresholds?: Record<string, number>
slas?: Record<string, { maxDurationMs: number; escalateTo?: string }>
}Classic positional form
The original signature still works — useful when you don't want the schema artifact (small actors, tests, one-offs).
function defineActor<TSchema extends ZodTypeAny, TMethods>(
name: string,
options: ActorOptions<TSchema, TMethods>,
): ActorDefinition<TSchema, TMethods>ActorOptions (classic)
interface ActorOptions<TSchema, TMethods> {
schema: TSchema // zod object describing the actor's data
key: string // field name in event payloads → actor id
initial: string // initial state name
states: Record<string, ActorStateConfig>
methods?: TMethods // pure invariant-enforcers callable via ctx.use(…)
// Studio-aware
stuckThresholds?: Record<string, number> // state → ms before "stuck"
slas?: Record<string, { maxDurationMs: number; escalateTo?: string }>
}
interface ActorStateConfig<TCtx> {
final?: boolean
on?: Record<string, ActorReaction<TCtx>> // event reactions
after?: Record<string, ActorTimerSpec> // deferred dispatches
}
interface ActorReaction<TCtx, TPayload> {
target?: string // transition target state; omit to stay
assign?: (state: Readonly<TCtx>, event: TPayload) => Partial<TCtx>
}
type ActorTimerSpec =
| string // action name; delay = timer key
| { delay: string; action: string; buildInput?: (state, key) => unknown }Example
const Submission = defineActor("submission", {
schema: SubmissionData, // z.object({ submissionId, studentId, status, verdict?, ... })
key: "submissionId",
initial: "submitted",
states: {
submitted: {
on: {
[AnswerSubmittedEvent.name]: {
assign: (_state, event) => ({ ...event, status: "submitted" }),
},
[SubmissionAutoGradedEvent.name]: {
target: "graded",
assign: (_state, event) => ({ status: "graded", verdict: event.verdict }),
},
[SubmissionFlaggedForReviewEvent.name]: {
target: "under-review",
},
},
},
"under-review": {
on: {
[SubmissionManuallyGradedEvent.name]: { target: "graded" },
},
after: {
// Schedule a deferred action while in this state.
// Cancelled automatically on state transition.
"review-reminder": {
delay: "3d", // or "1h", "5m", "30s"
action: "submissions.send-review-reminder",
buildInput: (_state, submissionId) => ({ submissionId }),
},
},
},
graded: { final: true }, // terminal state — no further events
},
// Studio: surfaces stuck instances after 48h in under-review
stuckThresholds: {
"under-review": 48 * 60 * 60 * 1000,
},
// Studio: hard SLA + escalation routing
slas: {
"under-review": {
maxDurationMs: 7 * 24 * 60 * 60 * 1000,
escalateTo: "curriculum-lead",
},
},
// Pure invariant-enforcing methods, callable via ctx.use(Submission, id)
methods: {
flag(state, reason: string) {
if (state.status !== "submitted") {
throw new Error("only submitted submissions can be flagged")
}
return SubmissionFlaggedForReview({ submissionId: state.submissionId, reason })
},
},
})Lifecycle
- First event arrives with this actor's
keyfield - Runtime creates an actor instance in the
initialstate if none exists - Looks up
states[current].on[event.name]for a matching reaction - Applies
assignto update data - If reaction has
target, transitions state — cancels oldaftertimers, schedules new ones - Persists via
actorStore.save() - Emits
actor.transitionedtelemetry on state change
Multi-tenancy
Actor instances are partitioned by envelope.tenant. School A's submissions and school B's submissions live in separate keys — (actor: "submission", key: "sub-1", tenant: "school-a") vs (..., tenant: "school-b").
ctx.use — the actor view
defineHandler(flagSubmission, async (input, ctx) => {
const submission = await ctx.use(Submission, input.submissionId)
submission.state // current data (readonly snapshot)
submission.stateName // "submitted" | "under-review" | "graded"
submission.key // "sub-abc-123"
return submission.flag(input.reason) // methods pre-bound to state
})The view is a snapshot — subsequent dispatches don't refresh it. Re-call ctx.use after a dispatch that mutated the actor.
Methods
Methods are pure: take state, return event(s) or read values. The runtime treats them as part of the actor's contract:
methods: {
// Mints an event
flag(state, reason) {
if (state.status !== "submitted") throw new Error("invariant")
return SubmissionFlaggedForReview({ ... })
},
// Pure read
canBeReviewed(state) {
return state.status === "under-review"
},
// Multiple events
closeOut(state) {
return [SubmissionGraded({ ... }), MetricsRecorded({ ... })]
},
},The framework calls them with the actor's current state pre-bound.
When state stays the same
If no target is specified, the actor stays in its current state. Data may still update via assign. actor.transitioned only emits on actual state changes — pure data updates don't fire it.
Final states
final: true marks a state as absorbing. The validator rejects on: entries on final states (events to that actor are silently dropped after final). Useful for archived, cancelled, completed etc.
Timers (after)
"under-review": {
after: {
"review-reminder": { delay: "3d", action: "submissions.send-review-reminder" },
},
}When the actor enters under-review:
- Runtime schedules a timer with
fireAt = now + parseDelay("3d") - Stored on the actor instance
- A periodic
runtime.fireDueTimers()(or BullMQ scheduler) dispatches the action when due - If the actor transitions OUT of
under-reviewbefore then, the timer is cancelled
parseDelay accepts: "500ms", "30s", "5m", "3h", "7d".
Timer-fired actions inherit the actor's tenant envelope, so cross-tenant state stays isolated.
See also
- defineSchema — data + lifecycle the schema-bound form binds to
- Concepts → Actor
- Actor methods → ctx.use
- Timer scheduling reference
- @nwire/store-mongo — persistent actor store