Skip to content

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.

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.

Every PersistentActor subclass implements three methods:

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 };
}

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 via onEvent. Side effects go here.
  • Reply without persisting — for read-only commands (e.g. { kind: 'get-balance' }), reach into state and tell the 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() },
() => {});
}
}
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:

  1. The event is durable — if the process crashes after this, the journal still has it. The next recovery picks it up.
  2. The state reflects itonEvent has run; this.state is the new state.
  3. 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.

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 command

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

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.

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.

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.

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.

The PersistentActor API reference covers the full base-class surface.