Actors + state machines
When a domain entity has invariants that two concurrent writes could break ("can't ship a cancelled order"; "can't add a seat to a full class"), wrap it in an actor. The actor's state is declared as a state machine — every transition is an event folded into the actor's data, and the only way to mutate state is to emit an event that one of the state's reactions accepts.
Minimal actor
The form takes a name first, a builder closure, and an options object carrying the defineSchema that fixes the actor's data shape, key, and lifecycle states. The closure declares the transitions with when and returns its methods as a plain object.
import { defineActor, defineSchema, eventFactory } from "@nwire/forge";
import { defineEvent } from "@nwire/messages";
import { z } from "zod";
const SeatWasBookedDef = defineEvent({
name: "classes.seat-was-booked",
schema: z.object({ classId: z.string(), studentId: z.string() }),
});
const SeatWasBooked = eventFactory(SeatWasBookedDef);
const ClassRosterData = defineSchema({
name: "class-roster",
key: "classId",
fields: {
classId: z.string(),
seats: z.number().int().nonnegative(),
students: z.array(z.string()),
},
states: { open: { initial: true } },
});
export const ClassRoster = defineActor(
"ClassRoster",
({ data, validate, recordThat, states, when }) => {
const { open } = states;
open(() => {
when(SeatWasBookedDef, (e, { assign }) => {
assign({ students: [...(data.students ?? []), e.studentId] });
});
});
const book = (studentId: string) => {
validate({ studentId }, [
() => (data.students?.length ?? 0) < (data.seats ?? 0) || "class is full",
]);
if (data.students?.includes(studentId)) return; // no-op
recordThat(SeatWasBooked({ classId: data.classId, studentId }));
};
return { book };
},
{ schema: ClassRosterData },
);Inside an action handler:
handler: async ({ input, actor }) => {
const roster = await actor(ClassRoster, input.classId);
await roster.book(input.studentId);
},actor(ClassRoster, id) loads the actor instance and returns a view that exposes state, stateName, key, and bound methods. A method records an event with recordThat (which the runtime publishes; the matching when then folds it into the next state), returns nothing for a no-op, or throws to reject the call.
Lifecycle states
For actors with a richer lifecycle (orders go placed → paid → shipped → delivered), declare the states and which reactions apply in each:
import { defineActor, defineSchema } from "@nwire/forge";
import { z } from "zod";
const OrderData = defineSchema({
name: "order",
key: "orderId",
fields: {
orderId: z.string(),
customerId: z.string(),
total: z.number().positive(),
},
states: {
placed: { initial: true },
paid: {},
shipped: {},
delivered: { final: true },
},
});
export const Order = defineActor(
"Order",
({ states, when }) => {
const { placed, paid, shipped } = states;
placed(() => when(OrderWasPaidDef, () => paid));
paid(() => when(OrderWasShippedDef, () => shipped));
shipped(() => when(OrderWasDeliveredDef, () => states.delivered));
},
{ schema: OrderData },
);Events that would transition from a state that does not declare a reaction for them are silently ignored; that's how the state machine prevents invalid transitions. Studio renders the state machine.
Concurrency model
Reactions are serialized per (actor, key, tenant) — two events that target the same actor instance fold one after the other, never interleaved. State never corrupts under concurrent reaction.
The command path — the action handler that calls use(Actor, id), evaluates a method's invariant guard, and emits an event — is not serialized by the in-memory store. If two concurrent action handlers race against the same actor key, both observe the same pre-state, both guard checks see the same data, and both emit events. The reducer then folds them serially, but the invariant decision has already been made on stale state. For workloads where command-side invariants must hold under concurrency, use a persistent actor store with row-level locking (@nwire/mongo, or any custom store whose lockKey implementation takes a real database lock).
This is a known limitation of the default InMemoryActorStore and is tracked for a runtime-level fix.
Where to next
defineActorreference — full signature, closure form, timer support, snapshots.- Projections + queries — fold the events the actor records into read models.
- Workflows — react to events the actor records.