Ventyd Logo

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.

User.schema.ts
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 user
  • event.profile_updated - Defines what can be updated
  • state - Defines what properties a user has
  • initialEventName - 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.

User.reducer.ts
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 default case
  • Never mutate prevState - return a new object
  • Use event data from event.body
  • Use event metadata like event.eventCreatedAt for timestamps

Create Your Entity Class

Now let's add business logic to our user entity.

User.ts
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 this for business logic
  • Give you a dispatch function to emit events

Set Up a Repository

The repository handles saving and loading entities from your database.

user.repository.ts
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:

main.ts
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 enthusiast

What Just Happened?

Let's trace what happened under the hood:

  1. User.create() dispatched a user:created event
  2. user.updateProfile() dispatched a user:profile_updated event
  3. userRepository.commit() saved both events to storage
  4. userRepository.findOne() loaded events and replayed them through the reducer to rebuild state

Next Steps

Congratulations! You've built your first event-sourced entity.

On this page