2025-04-05 5 min read

Event Sourcing Without the Ceremony: Pragmatic Implementation Guide

Event sourcing doesn't require frameworks or complexity. Learn how to build reliable, auditable systems with straightforward patterns and minimal overhead.

Event sourcing has a reputation for being overwrought. Teams hear "immutable event store" and imagine building Kafka pipelines, managing snapshots, and wrestling with eventual consistency. The reality? You can start simple—really simple—and scale thoughtfully.

Here's the core idea: instead of storing current state, store what happened. A user can't change their email directly. Instead, you record "EmailChanged" events. Rebuild state by replaying those events. That's it. Everything else is refinement.

Start with a Single Table

You don't need a specialized event store or framework. A basic table works fine:

sql
CREATE TABLE events (
  id BIGSERIAL PRIMARY KEY,
  aggregate_id UUID NOT NULL,
  event_type VARCHAR(255) NOT NULL,
  data JSONB NOT NULL,
  created_at TIMESTAMP DEFAULT NOW(),
  INDEX (aggregate_id, created_at)
);

Every change becomes a row. Write-once, read-many. Version your event payloads by nesting a version field inside

code
data
:

json
{
  "version": 1,
  "email": "user@example.com",
  "previous_email": "old@example.com"
}

When you need to change the shape, increment the version and handle both in your replay logic.

Implement a Minimal Event Handler

Load events and rebuild state—no magic required:

typescript
type Event = {
  id: bigint;
  aggregate_id: string;
  event_type: string;
  data: Record<string, unknown>;
  created_at: Date;
};

type UserState = {
  id: string;
  email: string;
  verified: boolean;
  version: number;
};

async function loadUserState(userId: string): Promise<UserState> {
  const events = await db.query(
    "SELECT * FROM events WHERE aggregate_id = $1 ORDER BY created_at",
    [userId]
  );

  let state: UserState = {
    id: userId,
    email: "",
    verified: false,
    version: 0,
  };

  for (const event of events) {
    switch (event.event_type) {
      case "UserCreated":
        state.email = event.data.email;
        break;
      case "EmailChanged":
        state.email = event.data.email;
        break;
      case "EmailVerified":
        state.verified = true;
        break;
    }
    state.version++;
  }

  return state;
}

That's the entire read path. No ORM, no query builder gymnastics. For writes, append an event and update your projection (more on that next):

typescript
async function changeEmail(
  userId: string,
  newEmail: string
): Promise<void> {
  const currentState = await loadUserState(userId);

  await db.query(
    "INSERT INTO events (aggregate_id, event_type, data) VALUES ($1, $2, $3)",
    [
      userId,
      "EmailChanged",
      JSON.stringify({
        version: 1,
        email: newEmail,
        previous_email: currentState.email,
      }),
    ]
  );
}

Add Projections When You Need Speed

Replaying all events every request gets slow. Add a simple cache or materialized view:

typescript
type CachedUserState = {
  user_id: string;
  email: string;
  verified: boolean;
  last_event_id: bigint;
};

async function syncProjection(userId: string): Promise<void> {
  const state = await loadUserState(userId);
  const lastKnownId = await getLastProjectedEventId(userId);

  // Only update if new events exist
  if (state.version > lastKnownId) {
    await db.query(
      "INSERT INTO user_projections (user_id, email, verified, last_event_id) VALUES ($1, $2, $3, $4) ON CONFLICT (user_id) DO UPDATE SET email = $2, verified = $3, last_event_id = $4",
      [userId, state.email, state.verified, state.version]
    );
  }
}

Read from the projection for speed, replay from events to rebuild if needed. Run projection updates asynchronously in a worker or trigger them on-demand.

Keep It Honest

Don't event-source everything. Use this pattern for entities where audit trails, temporal queries, or undo functionality matter: user accounts, financial transactions, permissions. Store shopping cart items or temporary UI state normally.

At LavaPi, we've shipped systems like this—no frameworks, just disciplined event capture and straightforward replay logic. Teams understand it, debug it, and extend it without friction.

Start with one table, write events as they happen, replay them to answer questions. Scale the projection layer when reads lag. That's event sourcing without the theater.

Share
LP

LavaPi Team

Digital Engineering Company

All articles