Skip to content

resonatehq-examples/example-event-sourcing-ts

Folders and files

NameName
Last commit message
Last commit date

Latest commit

 

History

1 Commit
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

Event Sourcing / CQRS Lite

Durable event stream processing with crash recovery. Processes a sequence of domain events into a state projection — one durable checkpoint per event. If the projection writer crashes at event 5, events 0–4 are cached. Processing resumes at event 5. No event is applied twice.

What This Demonstrates

  • Exactly-once event processing: each event application is an independent checkpoint; crash at event 5, events 0–4 are cached and not re-applied
  • State projection (CQRS read model): current account state derived by applying events in order — no shared mutable state, no coordination needed
  • Pure projection function: applyEvent(state, event) → nextState — deterministic, testable, no database required for the logic
  • Crash recovery without offset tracking: Resonate handles "where did we leave off" automatically; no Kafka offset management, no checkpoint tables

How It Works

The event loop is a generator function with one ctx.run() per event:

export function* processEventStream(ctx: Context, userId: string, events: UserEvent[], crashAtIndex: number) {
  let projection = initialProjection(userId);

  // Process each event as an independent durable checkpoint.
  // On crash: completed events return their cached projection state.
  // Loop resumes at the first uncached event. Zero re-processing.
  for (let i = 0; i < events.length; i++) {
    projection = yield* ctx.run(applyEvent, i, events[i], projection, crashAtIndex);
  }

  return { userId, eventsProcessed: projection.eventsProcessed, finalProjection: projection };
}

That's the entire "event sourcing" infrastructure. One for-loop with yield*.

The applyEvent function is pure: takes current state + one event, returns next state. No database writes, no side effects — just a switch statement over event types. Wrapped in ctx.run(), it becomes a durable step.

Domain Events

8 events representing an account lifecycle:

UserRegistered       → name, email set
ProfileUpdated       → name updated
SubscriptionActivated → subscription: "active"
OrderPlaced (×2)     → 2 active orders tracked
OrderShipped         → ord_001 moves active → shipped
OrderCancelled       → ord_002 moves active → cancelled count
SubscriptionRenewed  → renewals counter incremented

Restate Comparison

Restate handles event processing via Virtual Objects with per-key serialization. The eventtransactions example processes social media posts per user:

// Restate: event processing via Virtual Object
const userFeed = restate.object({
  name: "userFeed",
  handlers: {
    processPost: async (ctx: restate.ObjectContext, post: Post) => {
      const postId = await ctx.run(() => createPost(userId, post));
      while ((await ctx.run(() => getPostStatus(postId))) === PENDING) {
        await ctx.sleep({ seconds: 5 });
      }
      await ctx.run(() => updateUserFeed(userId, postId));
    },
  },
});

Resonate's approach: a sequential generator with yield* per event. No object declaration, no Kafka handler wiring. The loop IS the event processor.

Prerequisites

No external services required. Resonate runs in embedded mode.

Setup

git clone https://github.com/resonatehq-examples/example-event-sourcing-ts
cd example-event-sourcing-ts
bun install

Run It

Happy path — process all 8 events into a consistent projection:

bun start
=== Event Sourcing / CQRS Projection Demo ===
Mode: HAPPY PATH (process all 8 events into account projection)
Events: 8

  [event 00]  UserRegistered  →  name="Alice Chen"
  [event 01]  ProfileUpdated  →  name="Alice Chen-Watson"
  [event 02]  SubscriptionActivated  →  name="Alice Chen-Watson" sub=active
  [event 03]  OrderPlaced  →  ... sub=active orders=1 active=[ord_001]
  [event 04]  OrderPlaced  →  ... sub=active orders=2 active=[ord_001,ord_002]
  [event 05]  OrderShipped  →  ... active=[ord_002] shipped=[ord_001]
  [event 06]  OrderCancelled  →  ... shipped=[ord_001]
  [event 07]  SubscriptionRenewed  →  ... renewals=1

=== Final Projection ===
{
  "name": "Alice Chen-Watson",
  "subscription": "active",
  "subscriptionRenewals": 1,
  "totalOrders": 2,
  "cancelledOrders": 1,
  "shippedOrders": ["ord_001"],
  "eventsProcessed": 8,
  "wallTimeMs": 443
}

Crash mode — projection store fails writing event 05 (OrderShipped):

bun start:crash
  [event 00]  UserRegistered  →  name="Alice Chen"
  [event 01]  ProfileUpdated  →  name="Alice Chen-Watson"
  [event 02]  SubscriptionActivated  →  ... sub=active
  [event 03]  OrderPlaced  →  ... orders=1 active=[ord_001]
  [event 04]  OrderPlaced  →  ... orders=2 active=[ord_001,ord_002]
  [event 05]  OrderShipped  ✗  (projection store write failed)
Runtime. Function 'applyEvent' failed with 'Error: ...' (retrying in 2 secs)
  [event 05]  OrderShipped (retry 2)  →  ... active=[ord_002] shipped=[ord_001]
  [event 06]  OrderCancelled  →  ... shipped=[ord_001]
  [event 07]  SubscriptionRenewed  →  ... renewals=1

Notice: events 00-04 each logged once (cached before crash).
Event 05 (OrderShipped) failed → retried → succeeded.
The projection is consistent — no event was applied twice.

What to Observe

  1. No re-processing: events 00–04 each appear exactly once in crash mode. On retry, the loop replays but those events return their cached projection state immediately.
  2. Consistent final projection: the crash mode and happy path produce identical final projections — 2 orders, 1 shipped, 1 cancelled, 1 renewal.
  3. Retry is the SDK, not your code: Runtime. Function '...' failed (retrying in N secs) is Resonate. You don't write retry logic.
  4. Scale this up: change the event list to 10K events — the pattern is identical. Each event is one durable checkpoint.

File Structure

example-event-sourcing-ts/
├── src/
│   ├── index.ts    Entry point — Resonate setup and demo runner
│   ├── workflow.ts Event stream processor — the for-loop with yield*
│   └── events.ts   Event types, projection logic, sample data
├── package.json
└── tsconfig.json

Lines of code: ~402 total (including 8 event type definitions), ~25 lines of event processing logic (workflow.ts minus comments).

Comparison

Resonate Restate Kafka + Flink
Checkpoint mechanism ctx.run() per event Virtual Object state + Kafka Checkpoint intervals + offset commit
Exactly-once guarantee Promise store deduplication Transactional handler + journal At-least-once + idempotent sink
Event processing code ~25 LOC ~40 LOC ~100 LOC + connector config
Infrastructure None Restate server Kafka cluster + Flink cluster
Resume on crash From last checkpointed event From last committed offset From last checkpoint

The main difference from Restate's approach: Restate's eventtransactions pattern uses a Virtual Object where Kafka routes events to the correct per-user handler, with per-key serialization provided by the Virtual Object guarantee. Resonate's approach works differently — the workflow itself IS the processor, and you bring your own event queue (the events array). For Kafka-sourced events, see example-openai-deep-research-agent-kafka-ts.

Learn More

About

Durable event sourcing without an event store. 8 domain events build a projection. Crash mid-stream, resume at the exact event.

Resources

Stars

Watchers

Forks

Releases

No releases published

Packages

 
 
 

Contributors