Skip to content

Commit ec9e23d

Browse files
docs: add event sourcing architecture and transactions guides (#95)
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 386f7d5 commit ec9e23d

7 files changed

Lines changed: 448 additions & 299 deletions

File tree

.changeset/clear-buses-visit.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"ventyd": patch
3+
---
4+
5+
docs: add some production guide

docs/content/docs/architecture.mdx

Lines changed: 286 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,286 @@
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.

docs/content/docs/core-concepts.mdx

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -152,3 +152,7 @@ console.log(loaded.events);
152152
</Card>
153153
</Cards>
154154

155+
<Callout type="info">
156+
If state is always derived from events, what happens when an entity has thousands of events? And how do you query entities by fields other than `entityId`? These questions lead to two additional concepts — **snapshots** and **views** — that build on top of the event log. See [Events, Snapshots & Views](/docs/architecture) for the full picture.
157+
</Callout>
158+

docs/content/docs/database.mdx

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -95,3 +95,7 @@ const repository = createRepository(User, { adapter });
9595
<Callout type="info">
9696
Implement the adapter for your database of choice (MongoDB, PostgreSQL, MySQL, Redis, etc.) and you're ready to go!
9797
</Callout>
98+
99+
<Callout type="warn">
100+
In production, `commitEvents()` often needs to do more than just insert events — updating a read model, saving snapshots, and handling concurrent writes all need to happen atomically. When these operations aren't in one transaction, a crash between steps can leave your read model out of sync with your event log. See [Transactions](/docs/transactions) for how to handle this.
101+
</Callout>

docs/content/docs/meta.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,8 @@
1515
"plugins",
1616

1717
"---Production Guide---",
18-
"querying",
18+
"architecture",
19+
"transactions",
1920
"testing",
2021
"event-naming",
2122
"event-granularity",

0 commit comments

Comments
 (0)