Skip to content

PersistentFSM

PersistentFSM is the event-sourced variant of FSM. Each state transition becomes a persisted event — on restart, the FSM replays them and resumes in the exact state it was in.

import { PersistentFSM } from 'actor-ts';
type State = 'created' | 'submitted' | 'approved' | 'rejected';
type Data = { id: string; reviewer?: string; reason?: string };
type Msg = { kind: 'submit' } | { kind: 'approve'; reviewer: string } | { kind: 'reject'; reason: string };
class OrderApproval extends PersistentFSM<State, Data, Msg> {
readonly persistenceId = `order-${this.orderId}`;
constructor(private readonly orderId: string) {
super('created', { id: orderId });
this.when('created', (data, msg) => {
if (msg.kind === 'submit') return this.goto('submitted', data);
return this.stay(data);
});
this.when('submitted', (data, msg) => {
if (msg.kind === 'approve') return this.goto('approved', { ...data, reviewer: msg.reviewer });
if (msg.kind === 'reject') return this.goto('rejected', { ...data, reason: msg.reason });
return this.stay(data);
});
// Terminal states — no further transitions.
this.when('approved', (data) => this.stay(data));
this.when('rejected', (data) => this.stay(data));
}
}

A restart replays the persisted transitions (events), so the FSM picks up exactly where the previous incarnation left off — including the current state name, data, and any in-flight side-effect bookkeeping.

Every transition (goto) is persisted as a StateChangeEvent:

type StateChangeEvent<SName, SData> = {
from: SName;
to: SName;
data: SData;
ts: number;
};

The framework writes one event per transition. stay(...) does not persist — only state changes are recorded.

This is a deliberate choice: stays are usually data-only updates that don’t need durability for FSM correctness. If you need data updates persisted too, transition explicitly (goto('same-state', newData)) — but this generates noise events.

preStart():
└── read all StateChangeEvents for persistenceId
└── replay each: from → to, set data
└── current state + data = result of replay
└── onReceive available

Same idiom as PersistentActor — replay deterministically rebuilds the state machine’s exact position.

when handlers don’t run during replay — they’re for command handling (deciding the next transition), not state derivation. The transition itself is what’s stored; replay just sets the state.

class OrderApproval extends PersistentFSM<S, D, M> {
abstract readonly persistenceId: string;
// Optional overrides:
snapshotPolicy(): SnapshotPolicy<...> { return everyNEvents(50); }
eventAdapter(): EventAdapter<...> | undefined { return undefined; }
// ... same as PersistentActor
}

Inherits the same persistence machinery as PersistentActor:

  • persistenceId — the event stream key.
  • snapshotPolicy — periodic snapshots to bound replay.
  • eventAdapter / snapshotAdapter — schema migration.
  • tagsFor — tagging events for projection consumption.
override snapshotPolicy() { return everyNEvents(50); }

For long-running FSMs accumulating many transitions, snapshots bound replay. The snapshot serializes { stateName, stateData } — a compact blob.

Pick the interval based on transition frequency:

  • Few transitions per actor lifetime (~10) — no snapshot needed.
  • Many transitions (100+) — snapshot every 50-100 events.

See Snapshots for the general policy guidance.

class OrderApproval extends PersistentFSM<State, Data, Msg> {
// ...
override async onEnter(state: State, data: Data): Promise<void> {
if (state === 'submitted') {
await this.notifyReviewer(data); // ← side effect on transition
}
}
}

onEnter callbacks run only on state changes — both at transition time and NOT during replay. This is the key difference from PersistentActor: persistent FSMs treat state-entry as the side-effect hook, and recovery skips them.

This means side effects can live in onEnter without worry of duplication on restart. This is a meaningful ergonomic win over PersistentActor’s “side effects only in persist callback.”

Event flow during a transition:
1. Handler returns goto(newState, newData)
2. Framework persists StateChangeEvent
3. onExit(oldState, oldData) fires
4. onEnter(newState, newData) fires ← side effects here
5. Reply / further work proceeds
Event flow during recovery:
1. Read persisted events
2. For each: update state + data (no callbacks)
3. onReceive resumes — first new command runs normally

A multi-step saga:

type SagaState =
| 'reserved-inventory'
| 'charged-payment'
| 'scheduled-shipping'
| 'completed'
| 'compensating-inventory'
| 'compensating-payment'
| 'failed';
class OrderSaga extends PersistentFSM<SagaState, Data, Msg> {
// Each transition is persisted. A crash mid-saga resumes from
// the last persisted state — no work is lost.
this.when('reserved-inventory', (data, msg) => {
if (msg.kind === 'payment-success') return this.goto('charged-payment', { ... });
if (msg.kind === 'payment-failure') return this.goto('compensating-inventory', { ... });
return this.stay(data);
});
override async onEnter(state, data) {
if (state === 'charged-payment') {
await scheduleShipping(data); // safe — fires once per real transition
}
if (state === 'compensating-inventory') {
await releaseInventory(data);
}
}
}

The combination of persisted transitions + non-replayed onEnter callbacks makes this pattern much cleaner than PersistentActor-with-event-handlers for state-machine-shaped workflows.

The PersistentFSM API reference covers the full surface.