defineActor
defineActor declares a state machine bound to a single domain entity. DDD readers: this is the aggregate root.
It takes the name first, a builder closure, and an options object carrying the defineSchema that fixes the data shape, key, and lifecycle states. The closure declares the transitions with when, schedules timers with after, and returns its methods as a plain object.
Shape
Define data + lifecycle once via defineSchema, then bind transitions and methods in the closure.
import { defineActor } from "@nwire/forge"
import { SubmissionData } from "./submission.schema"
export const Submission = defineActor(
"submission",
({ data, validate, recordThat, states, when, after }) => {
const { submitted, underReview, graded } = states; // callable state bodies
submitted(() => {
when(AnswerWasFlagged, (e, { assign }) => {
assign({ confidence: e.confidence });
return underReview; // transition
});
});
underReview(() => {
when(SubmissionWasManuallyGraded, () => graded);
after("reminder", "3d", "submissions.send-reminder");
});
// `graded` is `final` in the schema — it declares no reactions.
const flag = (reason: string) => {
validate({ reason }, [() => data.verdict === undefined || "already graded"]);
recordThat(SubmissionWasFlagged({ submissionId: data.submissionId, reason }));
};
return { flag };
},
{ schema: SubmissionData }, // brings name, key, initial, final states
)The actor inherits key, initial, and the set of valid states from the schema. Mistakes are caught at boot:
- a
whenreaction scoped to afinalstate → error - if the schema declares no
finalstate, adeletedterminal is injected (a free soft-delete lifecycle)
Signature
function defineActor<TFields, TMethods>(
name: string,
body: (ctx: ActorBuilderContext<TState>) => TMethods,
options: ActorOptions<TFields>,
): ActorDefinitionThe builder context
The closure receives one object — the same context the workflow builder gets, minus effects:
interface ActorBuilderContext<TState> {
data: Readonly<TState> // live view of the instance's data
id: string // the instance id (the schema key's value)
states: Record<string, StateRef> // callable state bodies (+ camelCase aliases)
// The one event verb. Top-level = always-active; inside a state body = scoped.
when(event, (payload, { assign }) => StateRef | void): void
// Schedule a state-entry timer (cancelled on exit). Scoped to the state body.
after(name: string, delay: string, action: string | { name: string }): void
// For methods: enforce invariants and mint events.
validate(input, predicates, state?): void
recordThat(event): void
}Options
interface ActorOptions<TFields> {
schema: SchemaDefinition<TFields> // data shape + key + lifecycle
// Studio-aware
stuckThresholds?: Record<string, number> // state → ms before "stuck"
slas?: Record<string, { maxDurationMs: number; escalateTo?: string }>
}Methods are not an option — they're defined in the closure and returned as a plain object.
Reactions (when)
A when at the closure top is active in every non-final state; a when inside a state body fires only in that state, and the state-scoped one wins. The reaction returns a state to transition, or nothing to stay; assign(patch) folds the actor's data.
// top-level — always active
when(SubmissionWasDeleted, () => graded);
// state-scoped — only while `submitted`
submitted(() => {
when(AnswerWasSubmitted, (e, { assign }) => { assign({ ...e }); }); // stay
when(SubmissionWasAutoGraded, (e, { assign }) => { assign({ ...e }); return graded; });
});Methods
Methods are pure invariant-enforcers, defined in the closure and returned as a plain object. They close over the live data, validate invariants, and recordThat an event (or throw). They MUST NOT do I/O or read other actors.
export const Submission = defineActor(
"submission",
({ data, validate, recordThat }) => {
// Mints an event
const flag = (reason: string) => {
validate({ reason }, [() => data.status === "submitted" || "not flaggable"]);
recordThat(SubmissionWasFlagged({ submissionId: data.submissionId, reason }));
};
// Pure read
const canBeReviewed = () => data.status === "under-review";
return { flag, canBeReviewed };
},
{ schema: SubmissionData },
)The framework re-invokes the closure with the actor's live state, so methods read the right values and any recordThat events are collected after the call.
Usage from a handler
defineHandler(flagSubmission, async (input, ctx) => {
const submission = await ctx.actor(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.actor after a dispatch that mutated the actor.
Lifecycle
- First event arrives with this actor's
keyfield - Runtime creates an actor instance in the
initialstate if none exists - Looks up the reaction for the current state + event name
- Runs the
when, applying anyassignto update data - If the reaction returns a state, transitions — cancels old
aftertimers, schedules new ones - Persists via
actorStore.save() - Emits
actor.transitionedtelemetry on state change
When state stays the same
If a when returns nothing, 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 in the schema marks a state as absorbing. A when scoped to a final state is rejected at boot, and events to that actor are silently dropped after final. Useful for archived, cancelled, completed etc.
Timers (after)
underReview(() => {
after("review-reminder", "3d", "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.
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").
See also
- defineSchema — data + lifecycle the actor binds to
- Concepts → Actor
- Timer scheduling reference
- @nwire/mongo — persistent actor store