Skip to content

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.

ts
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 when reaction scoped to a final state → error
  • if the schema declares no final state, a deleted terminal is injected (a free soft-delete lifecycle)

Signature

ts
function defineActor<TFields, TMethods>(
  name: string,
  body: (ctx: ActorBuilderContext<TState>) => TMethods,
  options: ActorOptions<TFields>,
): ActorDefinition

The builder context

The closure receives one object — the same context the workflow builder gets, minus effects:

ts
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

ts
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.

ts
// 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.

ts
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

ts
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

  1. First event arrives with this actor's key field
  2. Runtime creates an actor instance in the initial state if none exists
  3. Looks up the reaction for the current state + event name
  4. Runs the when, applying any assign to update data
  5. If the reaction returns a state, transitions — cancels old after timers, schedules new ones
  6. Persists via actorStore.save()
  7. Emits actor.transitioned telemetry 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)

ts
underReview(() => {
  after("review-reminder", "3d", "submissions.send-review-reminder");
});

When the actor enters under-review:

  1. Runtime schedules a timer with fireAt = now + parseDelay("3d")
  2. Stored on the actor instance
  3. A periodic runtime.fireDueTimers() (or BullMQ scheduler) dispatches the action when due
  4. If the actor transitions OUT of under-review before 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

MIT licensed.