Quick Start
Build your first event-sourced entity in 5 minutes
Define Your Schema
First, let's define what events can happen to a user and what state they have.
import { defineSchema } from 'ventyd';
import { valibot, v } from 'ventyd/valibot';
export const userSchema = defineSchema("user", {
schema: valibot({
event: {
// Event that creates a new user
created: v.object({
email: v.pipe(v.string(), v.email()),
name: v.string(),
}),
// Event that updates user profile
profile_updated: v.object({
name: v.optional(v.string()),
bio: v.optional(v.string()),
}),
},
state: v.object({
email: v.string(),
name: v.string(),
bio: v.optional(v.string()),
}),
}),
initialEventName: "user:created", // The event that creates new users
});What's happening here?
event.created- Defines the data needed to create a userevent.profile_updated- Defines what can be updatedstate- Defines what properties a user hasinitialEventName- Tells Ventyd which event creates new entities
Event Naming Convention
Ventyd automatically adds the entity name as a prefix to event names. So created becomes user:created, and profile_updated becomes user:profile_updated.
Create a Reducer
The reducer is a function that takes the previous state and an event, and returns the new state.
import { defineReducer } from 'ventyd';
import { userSchema } from './user.schema';
export const userReducer = defineReducer(userSchema, (prevState, event) => {
switch (event.eventName) {
case "user:created":
// When a user is created, set initial state
return {
email: event.body.email,
name: event.body.name,
bio: undefined,
};
case "user:profile_updated":
// When profile is updated, merge changes
return {
...prevState,
...(event.body.name && { name: event.body.name }),
...(event.body.bio !== undefined && { bio: event.body.bio }),
};
default:
// For unknown events, return state unchanged
return prevState;
}
});Why a reducer?
Reducers are pure functions - they always produce the same output for the same input. This means:
- Events can be replayed to rebuild state
- State is predictable and testable
- Time travel debugging is possible
Reducer Tips
- Always handle the
defaultcase - Never mutate
prevState- return a new object - Use event data from
event.body - Use event metadata like
event.eventCreatedAtfor timestamps
Create Your Entity Class
Now let's add business logic to our user entity.
import { Entity, mutation } from 'ventyd';
import { userSchema } from './user.schema';
import { userReducer } from './user.reducer';
export class User extends Entity(userSchema, userReducer) {
// Getters for easy property access
get email() {
return this.state.email;
}
get name() {
return this.state.name;
}
get bio() {
return this.state.bio;
}
// Business logic: Update profile
updateProfile = mutation(
this,
(dispatch, updates: { name?: string; bio?: string }) => {
// Validate business rules
if (!updates.name && updates.bio === undefined) {
throw new Error("Must provide at least one field to update");
}
// Dispatch the event
dispatch("user:profile_updated", updates);
}
);
}What is mutation()?
The mutation() helper creates type-safe mutation methods that:
- Enforce readonly constraints (can't mutate loaded entities)
- Provide access to
thisfor business logic - Give you a
dispatchfunction to emit events
Set Up a Repository
The repository handles saving and loading entities from your database.
import { createRepository } from 'ventyd';
import type { Adapter } from 'ventyd';
import { User } from './user.entity';
// Simple in-memory adapter for development
const createInMemoryAdapter = (): Adapter => {
const events: any[] = [];
return {
async getEventsByEntityId({ entityName, entityId }) {
return events.filter(
e => e.entityName === entityName && e.entityId === entityId
);
},
async commitEvents({ events: newEvents }) {
events.push(...newEvents);
}
};
};
// Create the repository
export const userRepository = createRepository(User, {
adapter: createInMemoryAdapter()
});In-Memory Adapter
The in-memory adapter is great for development and testing, but don't use it in production! Data is lost when the process restarts. See Database for production options.
Use Your Entity
Now you can create and manipulate users:
import { User } from './user.entity';
import { userRepository } from './user.repository';
async function main() {
// Create a new user
const user = User.create({
body: {
email: "alice@example.com",
name: "Alice",
}
});
console.log("Created user:", user.entityId);
console.log("Email:", user.email);
console.log("Name:", user.name);
// Update the profile
user.updateProfile({
bio: "Software engineer and TypeScript enthusiast"
});
// Save to storage
await userRepository.commit(user);
console.log("✅ User saved!");
// Later... retrieve the user
const retrieved = await userRepository.findOne({
entityId: user.entityId
});
if (retrieved) {
console.log("Retrieved user:", retrieved.name);
console.log("Bio:", retrieved.bio);
}
}
main();Output:
Created user: user_abc123
Email: alice@example.com
Name: Alice
✅ User saved!
Retrieved user: Alice
Bio: Software engineer and TypeScript enthusiastWhat Just Happened?
Let's trace what happened under the hood:
- User.create() dispatched a
user:createdevent - user.updateProfile() dispatched a
user:profile_updatedevent - userRepository.commit() saved both events to storage
- userRepository.findOne() loaded events and replayed them through the reducer to rebuild state
Next Steps
Congratulations! You've built your first event-sourced entity.
