PersistentActor
PersistentActor<Command, 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, ActorSystemOptions, type ActorRef } from 'actor-ts';import { InMemoryJournal, InMemorySnapshotStore } from 'actor-ts';import { match } from 'ts-pattern';
type DepositCommand = { kind: 'deposit'; amount: number };type WithdrawCommand = { kind: 'withdraw'; amount: number };type GetBalanceCommand = { kind: 'get-balance'; replyTo: ActorRef<number> };type Command = DepositCommand | WithdrawCommand | GetBalanceCommand;
type DepositedEvent = { kind: 'deposited'; amount: number; ts: number };type WithdrawnEvent = { kind: 'withdrawn'; amount: number; ts: number };type Event = DepositedEvent | WithdrawnEvent;
const actorSystemOptions = ActorSystemOptions.create().withPersistence({ journal: new InMemoryJournal(), snapshotStore: new InMemorySnapshotStore(), });type State = { balance: number; };
class Account extends PersistentActor<Command, 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 { return match(e) .with({ kind: 'deposited' }, (ev) => this.onDeposited(state, ev)) .with({ kind: 'withdrawn' }, (ev) => this.onWithdrawn(state, ev)) .exhaustive(); }
private onDeposited(state: State, ev: DepositedEvent): State { return { balance: state.balance + ev.amount }; }
private onWithdrawn(state: State, ev: WithdrawnEvent): State { return { balance: state.balance - ev.amount }; }
// Handle a command — validate, persist if valid. onCommand(state: State, command: Command): void { match(command) .with({ kind: 'deposit' }, (c) => this.onDeposit(c)) .with({ kind: 'withdraw' }, (c) => this.onWithdraw(state, c)) .with({ kind: 'get-balance' }, (c) => this.onGetBalance(state, c)) .exhaustive(); }
private onDeposit(c: DepositCommand): void { this.persist( { kind: 'deposited', amount: c.amount, ts: Date.now() }, (next) => { /* side effects with the new state */ }, ); }
private onWithdraw(state: State, c: WithdrawCommand): void { if (state.balance < c.amount) { // Reject — don't persist anything. this.log.warn(`insufficient funds`); return; } this.persist( { kind: 'withdrawn', amount: c.amount, ts: Date.now() }, () => {}, ); }
private onGetBalance(state: State, c: GetBalanceCommand): void { c.replyTo.tell(state.balance); // read-only — no persist }}
// Setup — wire journal + snapshot store inline via the create option:const system = ActorSystem.create('demo', actorSystemOptions);
const account = system.spawn(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.
That is also why onEvent is synchronous while onCommand is
async — the asymmetry is the point, not an oversight. A command
decides, and deciding does I/O: persist writes to the journal.
An event is already a fact, and folding a fact into state is
arithmetic. Anything you would want to await here is precisely
what must not run again on recovery; put it in the persist
callback or onRecoveryComplete, neither of which replay.
Read state from the parameter, never this.state. During
replay onEvent runs detached, before this.state has been
assigned — and the DevTools time-travel panel borrows it the same
way, as a free fold over any point in history. A handler that
reaches for this.state works on the persist path and fails only
after a restart, which is the most expensive way to find out.
onCommand(state, command) → void | Promise<void>
Section titled “onCommand(state, command) → void | Promise<void>”Validates the command against the current state and decides what events to persist. Three valid outcomes:
this.persist(event, afterPersist)— 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, command: Command): void { match(command) .with({ kind: 'get-balance' }, (c) => this.onGetBalance(state, c)) .with({ kind: 'withdraw' }, (c) => this.onWithdraw(state, c)) .exhaustive();}
private onGetBalance(state: State, command: GetBalanceCommand): void { command.replyTo.tell(state.balance); // read-only — no persist}
private onWithdraw(state: State, command: WithdrawCommand): void { if (state.balance < command.amount) return; // reject this.persist({ kind: 'withdrawn', amount: command.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.
A throw from this hook is an ordinary actor failure and goes to
supervision; it is not a recovery failure, because the state
recovered fine.
When recovery fails
Section titled “When recovery fails”A corrupt event, a missing upcaster, a snapshot the integrity
check refuses, a journal handing back its events out of order —
recovery can throw, and then onRecoveryFailure is called.
It is a notification, not a decision. Recovery failure is terminal either way; the hook only chooses who hears about it:
// Default — rethrows, so the failure reaches supervision as an// ActorInitializationError and the strategy decides.onRecoveryFailure(reason: Error): void { throw reason; }
// Override that returns — you have taken the failure as handled,// and the actor is then stopped.override onRecoveryFailure(reason: Error): void { this.metrics.recoveryFailed.inc();}The two integrity failures are exported classes, so the hook can
tell them apart. Which of the two stores broke its contract is
the first thing an operator needs, and both carry the
persistenceId and the offending sequenceNr:
import { JournalIntegrityError, SnapshotIntegrityError } from 'actor-ts';
override onRecoveryFailure(reason: Error): void { if (reason instanceof JournalIntegrityError) { this.log.error(`journal broke at ${reason.sequenceNr} for ${reason.persistenceId}`); } else if (reason instanceof SnapshotIntegrityError) { this.log.error(`snapshot ${reason.sequenceNr} of ${reason.persistenceId} is not trustworthy`); }}The actor cannot carry on either way: state was never assigned
and lastSequenceNr is unknown, so there is no state in which it
could answer a command. Stopping is what keeps that visible —
commands already queued become dead letters instead of
disappearing into the stash, and the stop is published on the
event stream.
What replay refuses to fold
Section titled “What replay refuses to fold”Before a single event reaches onEvent, replay checks that what
journal.read() returned matches the Journal
contract: sequence
numbers ascending, contiguous, and inside the window that was
asked for. Anything else raises JournalIntegrityError.
Fatal rather than a warning, because the fold is recovery. A
shuffled stream replays history in an order it never happened in
— a disableAccount landing before the setPassword it was
meant to follow — and it leaves the actor’s sequence on the last
event delivered rather than the highest one, after which every
persist fails with a JournalConcurrencyError pointing at a
perfectly healthy journal, one restart away from its cause.
The same check refuses a history whose beginning is missing:
events compacted away with no snapshot covering them. That state
cannot be reconstructed, and folding the surviving tail onto
initialState() would invent one. Compact only past a
snapshot — deleteHistory(seq) keeps the snapshot at seq for
exactly this reason.
The DevTools time-travel panel shares this replay code but opts out of that last part. It asks what state looked like in the past, reaching for the newest snapshot before the target, so it routinely lands on a window whose covering snapshot has since been pruned. There it shows a partial fold — with the sequence it reached and the number of events it applied — rather than refusing to open.
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.
What a persistence ID may contain
Section titled “What a persistence ID may contain”An ID is rarely a constant — it is usually built from user input
(`account-${request.params.id}`, a sharding entity ID). It
is always bound as a query parameter, so SQL injection is not
reachable, but the ID is more than a value: it names a stream,
and several backends build a structured storage key out of it.
preStart therefore refuses an ID that cannot be one, before the
journal is ever touched:
| Rejected | Why |
|---|---|
| empty | every backend would happily write under '', so an ID some code path forgot to fill in silently shares one stream with every other actor that forgot the same thing |
| longer than 255 characters | the width of the persistence_id column in every relational dialect’s DDL, and part of its primary key — a longer ID is truncated (two IDs collapsing onto one stream) or rejected deep inside a driver |
/ or \ | the object-storage stores lay an ID out as a directory (<prefix><persistenceId>/<seq>.json) and read it back by listing that prefix, so a/b nests inside a — a’s loadLatest then returns a/b’s snapshot, and its delete prunes it |
. or .. (the whole ID) | traversal meaning, one level up, with the same consequence |
| control characters | IDs are interpolated into a log line on every recovery and every persist, so a newline lets a caller forge log records |
Everything else is allowed, deliberately. A comma is fine — the
comma-separated column in the SQLite journal carries tags, and
the ID is a separate bound column. A | is fine too, and the
chat example relies on it: a DM channel is
dm-channel-alice|bob. The projection offset store joins keys as
<projection>|seq|<persistenceId> with the ID last, so a |
inside it cannot split off an extra field, and nothing anywhere
splits an ID back apart.
The same rules run again inside journal.append() — defence in
depth for a journal reached without an actor — and, for
DurableStateActor, in the options
validator, where a violation is an OptionsError on the
persistenceId field.
Check your own IDs against them without starting an actor:
import { assertValidPersistenceId } from 'actor-ts';
assertValidPersistenceId(`account-${userId}`);Reading is never refused. Only writes are validated, so events
already stored under an ID that these rules reject stay reachable
via journal.read(oldId, 1) — copy them to a corrected ID and
delete the old stream.
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';
const v1ToV2Adapter: EventAdapter<EventV2> = { manifest: () => 'deposited', toJournal: (event) => ({ manifest: 'deposited', version: 2, payload: event }), fromJournal: (stored) => stored.version === 1 ? migrate(stored.payload as EventV1) : stored.payload as EventV2,};
class Account extends PersistentActor<...> { override eventAdapter() { return 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.fromJournal(stored) at read time. Backward
compatibility is the actor’s responsibility.
See Migration overview for the full migration story.
What events and state may contain
Section titled “What events and state may contain”Every store — journal, snapshot store, durable-state store, on every
backend — writes payloads in the tagged JSON tree format (the
same tree JsonSerializer uses), so rich
types survive the round-trip without any configuration. The
in-memory stores apply the same round-trip, so what works in a test
works in production and vice versa.
| Payload content | Round-trip behavior |
|---|---|
Plain objects, arrays, strings, finite numbers, booleans, null | Stored as plain JSON — byte-identical to before. |
Date, Map, Set, bigint, Uint8Array | Round-trip as real instances via type tags. |
BidirectionalMap | Round-trips as a real instance — one of two framework classes with a tag of its own (#1035). Only the forward pairs are stored; the inverse is rebuilt on decode. |
BidirectionalMultiMap | Round-trips as a real instance (#1037). Stored as a forward adjacency list; the inverse is rebuilt on decode, and a participant left with no partners is not stored at all. |
NaN, Infinity, -Infinity, -0 | Round-trip exactly — no more null / 0 (#889). |
RegExp (source + flags), URL | Round-trip as real instances. |
Error (incl. subclasses, cause, AggregateError) | Round-trips name + message + cause — deliberately without the stack: a persisted stack would leak filesystem paths into long-lived rows. |
Typed arrays (Int8Array … BigUint64Array), DataView, ArrayBuffer | Round-trip byte-exactly. |
undefined object property | Dropped, like JSON.stringify — reads back as undefined. |
undefined in arrays / Set / Map entries | Preserved as undefined (#889) — plain JSON’s null there would be a different value. |
new Number/String/Boolean wrappers | Unwrap to their primitive, like JSON.stringify. |
| Class instances | toJSON() is honoured; otherwise stored as plain { ...fields } — methods and instanceof are gone after recovery. Use plain data, an event adapter, or a per-store serializer (below). |
Functions, symbols, circular references, Promise, WeakMap / WeakSet | Throw at persist time (SerializationError — loud, instead of corrupting the stored row). |
Custom serializer per store
Section titled “Custom serializer per store”Every store options builder takes withSerializer(serializer) — the
way to preserve class identity or use a binary format for rows. See
using a custom serializer for persistence
for the framing, mixed-history behavior, and the in-memory exception.
CborSerializer carries the whole table above,
so swapping it in for row size costs you nothing from this list. Any
other serializer carries whatever it carries — a schema-driven one
(Avro, Protobuf) only knows the fields in its schema.
Rows written by earlier actor-ts versions used bare
JSON.stringify; they keep decoding unchanged — the tags only
appear in newly written rows where plain JSON would corrupt the
value.
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.
