Skip to main content
Version: 1.x

Build your first event-sourced app

This guide walks through modeling, dispatching actions to, and reading from a small event-sourced state in Act — start to finish, no prior framework knowledge required. By the end you'll have a runnable counter, a dispatched action, an event in the log, and a reconstructed state. You'll also know exactly which primitive to reach for next.

:::tip Skip the setup Want a running app in one command? npx degit rotorsoft/act-starter my-app gives you a minimal, heavily-commented starter — one state, one projection, one reaction, a generated REST API, and fixture()-based tests. This guide explains the concepts that starter uses. :::

What you'll build​

A counter with a single action (increment) and a single event (Incremented). Trivial domain on purpose — the point is to see every primitive in one place.

1. Set up​

mkdir my-counter && cd my-counter
pnpm init
pnpm add @rotorsoft/act zod
pnpm add -D typescript tsx @types/node
npx tsc --init --target ES2022 --module nodenext --moduleResolution nodenext --strict

Make sure package.json has "type": "module" so Node treats your .ts files as ESM.

2. Define the state​

Create counter.ts:

import { state } from "@rotorsoft/act";
import { z } from "zod";

export const Counter = state({ Counter: z.object({ count: z.number() }) })
.init(() => ({ count: 0 }))
.emits({ Incremented: z.object({ amount: z.number() }) })
.patch({
Incremented: ({ data }, state) => ({ count: state.count + data.amount }),
})
.on({ increment: z.object({ by: z.number() }) })
.emit((action) => ["Incremented", { amount: action.by }])
.build();

Three things just happened:

  1. state({ Counter: schema }) declared a state machine named Counter whose data shape is { count: number }. The Zod schema is the single source of truth — Act uses it for both runtime validation and TypeScript inference.
  2. .init(() => ({ count: 0 })) says "a brand-new Counter starts at 0." This is what app.load(Counter, "anything") returns when the stream has no events yet.
  3. .emits({ Incremented }) declares that this state can emit Incremented events. .patch({ Incremented: … }) is the reducer — the function Act runs when replaying events to rebuild state. .on({ increment }) .emit(...) declares an action: a command that takes { by: number }, validates it, and emits an Incremented event with the same amount.

Note the asymmetry: the action is increment (lowercase, intent), the event is Incremented (PascalCase, fact). Act doesn't enforce this naming, but it's the convention.

3. Wire the orchestrator​

Create index.ts:

import { act } from "@rotorsoft/act";
import { Counter } from "./counter.js";

const app = act().withState(Counter).build();
const actor = { id: "alice", name: "Alice" };

await app.do("increment", { stream: "counter-1", actor }, { by: 5 });
await app.do("increment", { stream: "counter-1", actor }, { by: 3 });

const snapshot = await app.load(Counter, "counter-1");
console.log(snapshot.state); // { count: 8 }
console.log(snapshot.version); // 1 (zero-indexed: events 0 and 1)

Run it: npx tsx index.ts. You should see { count: 8 } and 1.

What happened:

  • act().withState(Counter).build() composes everything into a runnable orchestrator. By default it uses an in-memory event store and an in-memory cache — perfect for development and tests.
  • app.do(action, target, payload) dispatches an action. Act validates the payload, checks any preconditions, applies the reducer, commits the resulting events to the store, updates the cache, and returns one snapshot per emitted event.
  • app.load(State, stream) reconstructs current state by replaying events from the cache or store. The version is the 0-indexed sequence number of the last event.

4. Add business rules​

Real domains have rules: "you can't decrement below zero", "you can't close a ticket twice". Act declares the ones an action must pass as named preconditions with .requires([...]), checked before it runs. A precondition reads the state, the actor and the action's payload; one that ignores the payload (typed over unknown) serves every action that needs it.

"You can't decrement below zero" depends on how much you subtract, so it reads the payload:

import { type Precondition } from "@rotorsoft/act";

const notLocked: Precondition<{ locked?: boolean }, unknown> = {
description: "Counter must not be locked",
holds: (_, state) => !state.locked,
};

const staysPositive: Precondition<{ count: number }, { by: number }> = {
description: "Count cannot go negative",
holds: (action, state) => state.count - action.by >= 0,
};

// Add a `decrement` action that reuses the same Incremented event
// (with a negative amount), gated by both rules:
.on({ decrement: z.object({ by: z.number() }) })
.requires([notLocked, staysPositive])
.emit((action) => ["Incremented", { amount: -action.by }])

The rules run in the order you declare them. The first rule that fails throws PreconditionError before any event is committed. State doesn't change, and the caller sees a typed error whose message is "decrement failed precondition: Count cannot go negative" and whose description names the rule. The act CLI, the domain diagram and the flow animations list them by name.

What must hold after every action is an invariant, and it belongs in the state's schema rather than on each action: z.object({ count: z.number().min(0) }) says the count is never negative, whatever action runs. Every action checks it before it commits, so a state that breaks it is refused and never stored. See state-management § Invariants.

5. Inspect what happened​

The event log is the audit trail. You can query it directly:

const events = await app.query_array({ stream: "counter-1", stream_exact: true });
events.forEach((e) => {
console.log(`v${e.version}: ${e.name} by ${e.meta.causation.action.actor.name}`);
});
// v0: Incremented by Alice
// v1: Incremented by Alice

Every event carries its action's actor, correlation id (request trace), and causation (what triggered it). This is what makes time-travel and debugging possible.

You can also reconstruct state at a specific point in time:

// State just before event id 5
const past = await app.load(Counter, "counter-1", undefined, { before: 5 });

See Event Sourcing & Processing → Time-travel queries for the full filter set.

6. Where to go next​

You've covered the four primitives that show up in every Act app:

  • State (state()) — what your domain looks like and how events transform it
  • Actions (.on().emit()) — typed intents that produce events
  • Business rules: preconditions (.requires()) checked before the handler, invariants (the state schema) checked before the commit
  • Events (.emits()/.patch()) — the immutable record

The next things you'll want, in roughly the order you'll need them:

  1. A real database. Replace the in-memory store with PostgreSQL or SQLite — see Configuration → Store.
  2. A read model. Counters fit in events. Bigger queries — "all open tickets assigned to me" — want a projection that writes to a table you can index. See Wiring projections to a database.
  3. Reactions. When one event should trigger another action (e.g. OrderPlaced → ReserveInventory), reach for slices and reactions. See State Management → Slices.
  4. Production wiring. Lifecycle events, settle-on-commit, graceful shutdown, snapshot policy — see Production checklist.