|
| 1 | +--- |
| 2 | +title: Events, Snapshots & Views |
| 3 | +description: The three representations of truth in event sourcing |
| 4 | +--- |
| 5 | + |
| 6 | +## One Entity, Three Faces |
| 7 | + |
| 8 | +In a traditional CRUD system, your data has one representation: the current row in a database table. Event sourcing splits this into three distinct structures, each serving a different purpose: |
| 9 | + |
| 10 | +- **Events** — what happened (the write model) |
| 11 | +- **Snapshots** — a cached checkpoint of state at a point in time |
| 12 | +- **Views** — a queryable projection shaped for reading (the read model) |
| 13 | + |
| 14 | +These are not three copies of the same data. They are three *perspectives* on the same truth, each optimized for a different access pattern. Understanding when and why you need each one is the key to building event-sourced systems that stay simple as they grow. |
| 15 | + |
| 16 | +## Events: The Append-Only Log |
| 17 | + |
| 18 | +Events are the source of truth. Everything else is derived. |
| 19 | + |
| 20 | +```typescript |
| 21 | +{ eventName: "user:created", version: 1, body: { email: "alice@example.com", name: "Alice" } } |
| 22 | +{ eventName: "user:name_updated", version: 2, body: { name: "Alice Cooper" } } |
| 23 | +{ eventName: "user:verified", version: 3, body: {} } |
| 24 | +``` |
| 25 | + |
| 26 | +An event records a fact that happened in the past. It is immutable — you don't update or delete events, you only append new ones. This constraint may feel limiting, but it is the source of event sourcing's power: |
| 27 | + |
| 28 | +- **Auditability**: You can always answer "what happened and when?" |
| 29 | +- **Replayability**: You can rebuild any derived state from scratch |
| 30 | +- **Temporal queries**: You can reconstruct state at any point in history |
| 31 | + |
| 32 | +The `version` field gives each event a monotonically increasing sequence number within its entity. This serves two purposes: it defines a total ordering (more reliable than timestamps), and it enables optimistic concurrency control. When two processes try to write `version: 4` simultaneously, only one can succeed. |
| 33 | + |
| 34 | +### The Mental Model |
| 35 | + |
| 36 | +Think of events like an accounting ledger. You never erase a transaction — if you made a mistake, you add a correcting entry. The current balance is always derivable by replaying the ledger from the beginning. |
| 37 | + |
| 38 | +This is exactly what Ventyd's `findOne()` does: load all events, replay them through the reducer, and return the resulting state. |
| 39 | + |
| 40 | +## Snapshots: Checkpoint Optimization |
| 41 | + |
| 42 | +Replaying all events works perfectly for entities with tens or even hundreds of events. But what about an entity with 10,000 events? Replaying all of them on every load becomes expensive. |
| 43 | + |
| 44 | +Snapshots solve this by periodically capturing the entity's state at a specific version: |
| 45 | + |
| 46 | +``` |
| 47 | +Snapshot: { version: 100, state: { email: "alice@example.com", name: "Alice Cooper", ... } } |
| 48 | +``` |
| 49 | + |
| 50 | +Now, instead of replaying 150 events, Ventyd can: |
| 51 | +1. Load the snapshot at version 100 |
| 52 | +2. Load only events after version 100 (events 101-150) |
| 53 | +3. Replay those 50 events on top of the snapshot |
| 54 | + |
| 55 | +The entity's state is identical either way — snapshots are purely an optimization. You can delete all snapshots and the system will still work correctly, just slower. |
| 56 | + |
| 57 | +### When to Snapshot |
| 58 | + |
| 59 | +Configure snapshot frequency based on your entity's event volume: |
| 60 | + |
| 61 | +```typescript |
| 62 | +const repository = createRepository(User, { |
| 63 | + adapter, |
| 64 | + snapshot: { |
| 65 | + frequency: 100, // Save a snapshot every 100 events |
| 66 | + }, |
| 67 | +}); |
| 68 | +``` |
| 69 | + |
| 70 | +The Prisma adapter writes snapshots inside the same transaction as events, so they're always consistent: |
| 71 | + |
| 72 | +``` |
| 73 | +Transaction: |
| 74 | + 1. INSERT events (versions 99, 100) |
| 75 | + 2. UPSERT view |
| 76 | + 3. UPSERT snapshot at version 100 ← only when version % frequency === 0 |
| 77 | +``` |
| 78 | + |
| 79 | +### The Mental Model |
| 80 | + |
| 81 | +Snapshots are like bookmarks in a long book. You don't need them to read the book — you can always start from page one. But if you know you'll keep coming back to chapter 12, it's nice to not flip through the first 11 chapters every time. |
| 82 | + |
| 83 | +If a bookmark gets lost or damaged, you lose nothing. You just have to flip from the beginning next time. This is why snapshots are stored with a fixed schema (`entityId`, `entityName`, `state`, `version`) and don't require user-defined mappings — they exist purely to serve the event-sourcing machinery. |
| 84 | + |
| 85 | +## Views: The Read Model |
| 86 | + |
| 87 | +Events are optimized for writing: append-only, ordered by version, indexed by `entityId`. But most applications need to *query* data in ways that don't align with this structure: |
| 88 | + |
| 89 | +- "Find all active users" |
| 90 | +- "List orders by status" |
| 91 | +- "Search users by email" |
| 92 | + |
| 93 | +Ventyd repositories only support querying by `entityId`: |
| 94 | + |
| 95 | +```typescript |
| 96 | +// ✅ Built-in: Query by entityId |
| 97 | +const user = await repository.findOne({ entityId: "user-123" }); |
| 98 | + |
| 99 | +// ❌ Not supported: Query by other fields |
| 100 | +const user = await repository.findOne({ email: "alice@example.com" }); |
| 101 | +``` |
| 102 | + |
| 103 | +This is where views come in. A view (also called a projection or read model) is a denormalized representation of your entity's state, shaped for querying: |
| 104 | + |
| 105 | +```sql |
| 106 | +-- Event table (write model): optimized for append and replay |
| 107 | +CREATE TABLE events ( |
| 108 | + event_id TEXT PRIMARY KEY, |
| 109 | + entity_id TEXT, |
| 110 | + entity_name TEXT, |
| 111 | + event_name TEXT, |
| 112 | + version INTEGER, |
| 113 | + body JSONB, |
| 114 | + UNIQUE (entity_id, version) |
| 115 | +); |
| 116 | + |
| 117 | +-- View table (read model): optimized for queries |
| 118 | +CREATE TABLE user_view ( |
| 119 | + entity_id TEXT PRIMARY KEY, |
| 120 | + email TEXT UNIQUE, |
| 121 | + name TEXT, |
| 122 | + is_verified BOOLEAN, |
| 123 | + created_at TIMESTAMP, |
| 124 | + version INTEGER |
| 125 | +); |
| 126 | +``` |
| 127 | + |
| 128 | +The event table stores *what happened*. The view table stores *what things look like now*. They contain the same information in different shapes. |
| 129 | + |
| 130 | +### Transactional Consistency |
| 131 | + |
| 132 | +In the Prisma adapter, the view is updated in the same transaction as the event insert: |
| 133 | + |
| 134 | +```typescript |
| 135 | +const viewRow = entityToViewRow({ entityId, entityName, state, version }); |
| 136 | + |
| 137 | +await prisma.$transaction([ |
| 138 | + tables.event.createMany({ data: eventRows }), |
| 139 | + tables.view.upsert({ where: { entityId }, update: viewRow, create: viewRow }), |
| 140 | +]); |
| 141 | +``` |
| 142 | + |
| 143 | +This means your view is never stale relative to your events. The moment events are committed, the view reflects them. This is a significant advantage over asynchronous projection patterns where a separate process consumes events and updates views with some delay. |
| 144 | + |
| 145 | +### The Mental Model |
| 146 | + |
| 147 | +If events are a journal ("today I deposited $100, then withdrew $30"), the view is your bank statement — a summary designed for quick answers. You can always regenerate the statement from the journal, but the statement is what you check when you want to know your balance. |
| 148 | + |
| 149 | +The `entityToViewRow` function is where you define the shape of this summary. It receives the full entity state and returns a row optimized for your query patterns: |
| 150 | + |
| 151 | +```typescript |
| 152 | +entityToViewRow({ entityId, entityName, state, version }) { |
| 153 | + return { |
| 154 | + entityId, |
| 155 | + email: state.email, |
| 156 | + name: state.name, |
| 157 | + isVerified: state.isVerified, |
| 158 | + createdAt: state.createdAt, |
| 159 | + }; |
| 160 | +} |
| 161 | +``` |
| 162 | + |
| 163 | +### Querying with Views |
| 164 | + |
| 165 | +Once you have a view table, querying by custom fields is straightforward — query the view, then load the entity if you need to mutate it: |
| 166 | + |
| 167 | +```typescript |
| 168 | +async function findUserByEmail(email: string) { |
| 169 | + // 1. Look up entityId from view |
| 170 | + const record = await db.userView.findOne({ email }); |
| 171 | + |
| 172 | + if (!record) { |
| 173 | + return null; |
| 174 | + } |
| 175 | + |
| 176 | + // 2. Load entity by entityId |
| 177 | + return userRepository.findOne({ entityId: record.entityId }); |
| 178 | +} |
| 179 | + |
| 180 | +// Usage |
| 181 | +const user = await findUserByEmail("alice@example.com"); |
| 182 | +if (user) { |
| 183 | + console.log(user.state.nickname); |
| 184 | +} |
| 185 | +``` |
| 186 | + |
| 187 | +For read-only access, you can skip the event replay entirely and use `Entity.load()` to create a readonly entity directly from the view's state: |
| 188 | + |
| 189 | +```typescript |
| 190 | +async function getUserByEmail(email: string) { |
| 191 | + const record = await db.userView.findOne({ email }); |
| 192 | + if (!record) return null; |
| 193 | + |
| 194 | + // Create readonly entity from view state — no event replay needed |
| 195 | + const user = User.load({ |
| 196 | + entityId: record.entityId, |
| 197 | + state: record, |
| 198 | + }); |
| 199 | + |
| 200 | + return user; |
| 201 | +} |
| 202 | + |
| 203 | +// ✅ Fast readonly access |
| 204 | +const user = await getUserByEmail("alice@example.com"); |
| 205 | +console.log(user.state.nickname); |
| 206 | + |
| 207 | +// ❌ Cannot mutate — readonly entity |
| 208 | +// user.updateProfile({ ... }); // Type error |
| 209 | +``` |
| 210 | + |
| 211 | +### Querying with Plugins |
| 212 | + |
| 213 | +If you're not using the Prisma adapter's built-in view table, you can maintain your own indexes using plugins: |
| 214 | + |
| 215 | +```typescript |
| 216 | +const emailIndexPlugin: Plugin = { |
| 217 | + async onCommitted({ entityName, entityId, state }) { |
| 218 | + if (entityName !== "user") return; |
| 219 | + |
| 220 | + await db.userEmails.upsert( |
| 221 | + { email: state.email }, |
| 222 | + { |
| 223 | + email: state.email, |
| 224 | + entityId: entityId, |
| 225 | + updatedAt: new Date() |
| 226 | + } |
| 227 | + ); |
| 228 | + } |
| 229 | +}; |
| 230 | + |
| 231 | +const userRepository = createRepository(User, { |
| 232 | + adapter, |
| 233 | + plugins: [emailIndexPlugin] |
| 234 | +}); |
| 235 | +``` |
| 236 | + |
| 237 | +Plugins run after the commit transaction completes. This means the index update is eventually consistent — if the plugin fails, your events are saved but the index may be stale. For strict consistency, prefer updating the view inside the adapter's transaction (as the Prisma adapter does). |
| 238 | + |
| 239 | +## How the Three Fit Together |
| 240 | + |
| 241 | +``` |
| 242 | + Write path Read path |
| 243 | + ───────── ───────── |
| 244 | +
|
| 245 | + mutation() View table |
| 246 | + │ ↑ |
| 247 | + ▼ │ |
| 248 | + dispatch event entityToViewRow() |
| 249 | + │ ↑ |
| 250 | + ▼ │ |
| 251 | + commit ──── transaction ───┬──► Event table │ |
| 252 | + │ │ |
| 253 | + ├──► View table ─┘ |
| 254 | + │ |
| 255 | + └──► Snapshot table |
| 256 | + (if version % N === 0) |
| 257 | +
|
| 258 | + Reload path |
| 259 | + ─────────── |
| 260 | +
|
| 261 | + findOne() |
| 262 | + │ |
| 263 | + ├── Load snapshot (if exists) |
| 264 | + │ |
| 265 | + ├── Load events after snapshot version |
| 266 | + │ |
| 267 | + └── Replay events on top of snapshot → entity state |
| 268 | +``` |
| 269 | + |
| 270 | +- **Events** are written on every commit. They are the source of truth. |
| 271 | +- **Views** are updated on every commit, in the same transaction. They serve queries. |
| 272 | +- **Snapshots** are written periodically. They accelerate reloads. |
| 273 | + |
| 274 | +Each has a clear role. Events never lie. Views are always in sync. Snapshots are disposable. |
| 275 | + |
| 276 | +## Choosing What You Need |
| 277 | + |
| 278 | +Not every system needs all three from day one: |
| 279 | + |
| 280 | +| Stage | What you need | Why | |
| 281 | +|---|---|---| |
| 282 | +| **Starting out** | Events only | Simple, correct, and sufficient for low-volume entities | |
| 283 | +| **Adding queries** | Events + Views | When you need to query by fields other than `entityId` | |
| 284 | +| **Scaling reads** | Events + Views + Snapshots | When entities accumulate hundreds of events and reload becomes slow | |
| 285 | + |
| 286 | +Start simple. Add views when your query patterns demand them. Add snapshots when your event counts warrant them. The event log is always there as your foundation — everything else is built on top. |
0 commit comments