PersistentActor
PersistentActor<Cmd, Event, State> is the framework’s
event-sourcing base class. The model:
- Commands come in.
- The command handler validates and decides what facts happened — those facts are events, persisted to the journal.
- After persistence, a pure event handler updates state.
On startup, the framework reads the journal and replays every event through the event handler. The resulting state is what the actor sees on its first command — wherever the last instance left off, this one resumes.
A minimal example
Section titled “A minimal example”import { PersistentActor, ActorSystem, Props } from 'actor-ts';import { PersistenceExtensionId, InMemoryJournal, InMemorySnapshotStore } from 'actor-ts';
type Cmd = | { kind: 'deposit'; amount: number } | { kind: 'withdraw'; amount: number };
type Event = | { kind: 'deposited'; amount: number; ts: number } | { kind: 'withdrawn'; amount: number; ts: number };
interface State { balance: number; }
class Account extends PersistentActor<Cmd, Event, State> { readonly persistenceId = 'account-42';
initialState(): State { return { balance: 0 }; }
// Pure: state + event → new state. Runs both on persist + recovery. onEvent(state: State, e: Event): State { if (e.kind === 'deposited') return { balance: state.balance + e.amount }; if (e.kind === 'withdrawn') return { balance: state.balance - e.amount }; return state; }
// Handle a command — validate, persist if valid. onCommand(state: State, cmd: Cmd): void { if (cmd.kind === 'deposit') { this.persist( { kind: 'deposited', amount: cmd.amount, ts: Date.now() }, (next) => { /* side effects with the new state */ }, ); } else if (cmd.kind === 'withdraw') { if (state.balance < cmd.amount) { // Reject — don't persist anything. this.log.warn(`insufficient funds`); return; } this.persist( { kind: 'withdrawn', amount: cmd.amount, ts: Date.now() }, () => {}, ); } }}
// Setup:const system = ActorSystem.create('demo');system.extension(PersistenceExtensionId).configure({ journal: new InMemoryJournal(), snapshotStore: new InMemorySnapshotStore(),});
const account = system.actorOf(Props.create(() => new Account()), 'account-42');account.tell({ kind: 'deposit', amount: 100 });account.tell({ kind: 'withdraw', amount: 30 });// state is { balance: 70 } — and stays that way across restarts.The three abstract methods
Section titled “The three abstract methods”Every PersistentActor subclass implements three methods:
initialState()
Section titled “initialState()”The state before any events. Called at the start of recovery, and
the result is what onEvent builds on top of.
initialState(): State { return { balance: 0 };}onEvent(state, event) → newState
Section titled “onEvent(state, event) → newState”Pure — no side effects, no tells, no awaits. Just
state + event → state. This function runs both:
- On replay — once per persisted event, when the actor starts and the journal is replayed.
- After persist — once when a new event lands.
Pure-ness matters because the same events will replay many times
over the actor’s lifetime. A side effect inside onEvent runs
during every recovery, which means duplicate emails / duplicate
HTTP calls / duplicated everything.
onCommand(state, cmd) → void | Promise<void>
Section titled “onCommand(state, cmd) → void | Promise<void>”Validates the command against the current state and decides what events to persist. Three valid outcomes:
this.persist(event, cb)— persist an event. The callback runs with the new state once the event is appended and applied viaonEvent. Side effects go here.- Reply without persisting — for read-only commands (e.g.
{ kind: 'get-balance' }), reach intostateandtellthe reply directly. - Reject — log, ignore, or reply with an error. No events written.
onCommand(state: State, cmd: Cmd): void { if (cmd.kind === 'get-balance') { cmd.replyTo.tell(state.balance); // read-only — no persist return; } if (cmd.kind === 'withdraw') { if (state.balance < cmd.amount) return; // reject this.persist({ kind: 'withdrawn', amount: cmd.amount, ts: Date.now() }, () => {}); }}The persist callback
Section titled “The persist callback”this.persist(event, (newState) => { // 1. event has been written to the journal // 2. onEvent has been called; this.state and `newState` reflect it // 3. it's safe to do side effects here this.sender.forEach(s => s.tell({ ok: true, balance: newState.balance }));});Three guarantees the callback gives you:
- The event is durable — if the process crashes after this, the journal still has it. The next recovery picks it up.
- The state reflects it —
onEventhas run;this.stateis the new state. - Commands are stashed during the persist — incoming commands wait until the persist completes and its callback fires. No interleaving.
The third point is what makes persist safe to use as the
“transaction boundary” for command processing. Side effects (replies,
notifications, follow-up tells) belong in the callback.
Recovery
Section titled “Recovery”When the actor starts:
preStart() runs: → load latest snapshot (if any) → set state, seqNr → read events from journal starting at seqNr+1 → for each event, state = onEvent(state, event) → onRecoveryComplete(state) → ready to process the first commandWhile recovery is running, no commands are processed. The mailbox piles up; once recovery finishes, the actor drains them in order against the recovered state.
onRecoveryComplete(state) is an optional hook fired after the
last event is replayed. Use it for one-time post-recovery setup
(register watchers, fetch related actors, etc.) — but not for
side effects per event, which would duplicate on every restart.
Persistence ID
Section titled “Persistence ID”Every PersistentActor declares a persistenceId:
class Account extends PersistentActor<...> { readonly persistenceId = 'account-42';}The ID identifies the event stream in the journal. Two
actors with the same persistenceId would share the same event
log — usually a bug.
For per-entity actors (one account per user, one cart per user), make the ID dependent on the entity:
class Account extends PersistentActor<...> { constructor(public readonly userId: string) { super(); } readonly persistenceId = `account-${this.userId}`;}For sharded entities, the shard region typically passes the entity ID via constructor, and the persistence ID derives from it.
Snapshots
Section titled “Snapshots”Replaying 100 000 events at startup is slow. Configure a snapshot policy:
import { everyNEvents } from 'actor-ts';
class Account extends PersistentActor<...> { override snapshotPolicy() { return everyNEvents(100); } // After every 100 events, the current state is snapshotted.}The framework writes a snapshot via the snapshot store; on recovery, it loads the snapshot first and only replays events after that snapshot’s seqNr.
everyNEvents(N) is the common case. For custom policies (snapshot
on a specific event kind, time-based), implement:
override snapshotPolicy() { return (seqNr, state, event) => event.kind === 'finalized';}See Snapshots for the full configuration.
Tagging events for projections
Section titled “Tagging events for projections”class Account extends PersistentActor<...> { override tagsFor(event: Event): ReadonlyArray<string> | undefined { return ['account']; // or based on event kind }}Projections read events from
the journal by tag. Tagging an event makes it discoverable to
a read-side view that subscribes to 'account' events.
Returning undefined (the default) means “no tags” — fine if you
don’t have projections yet.
Schema evolution — event adapters
Section titled “Schema evolution — event adapters”When event shapes change over time (a field is renamed, a value is split, an enum is added), old events stay in the journal forever. The event adapter upgrades them on read:
import { EventAdapter } from 'actor-ts';
class V1ToV2Adapter implements EventAdapter<EventV2> { upcast(stored: unknown, version: number): EventV2 { if (version === 1) return migrate(stored as EventV1); return stored as EventV2; }}
class Account extends PersistentActor<...> { override eventAdapter() { return new V1ToV2Adapter(); }}With an adapter configured, every event is wrapped in a { _v, _t, _e }
envelope (version + type + payload) at persist time, and unwrapped
through adapter.upcast(stored, version) at read time. Backward
compatibility is the actor’s responsibility.
See Migration overview for the full migration story.
Common pitfalls
Section titled “Common pitfalls”Where to next
Section titled “Where to next”- Persistence overview — the bigger picture: PersistentActor vs DurableStateActor.
- Snapshots — replay-window reduction in detail.
- Projections — read-side views over the event stream.
- Journals — SQLite — production journal for single-node.
- Migration overview — evolving event schemas.
- Replicated event sourcing — multi-writer event sourcing for cluster setups.
The PersistentActor
API reference covers the full base-class surface.