DEV Community

myougaTheAxo
myougaTheAxo

Posted on

Designing Event Sourcing with Claude Code: Event Store, Projections, CQRS

Introduction

"Why was this order cancelled?" — with normal CRUD, only the final state remains. Event Sourcing stores all change history as events, allowing you to reconstruct state at any point in time. Generate designs with Claude Code.


CLAUDE.md Event Sourcing Rules

## Event Sourcing Design Rules

### Event Design
- Name events in past tense (OrderPlaced, OrderCancelled)
- Events are immutable (append-only)
- Include aggregateId, version, occurredAt in every event
- Payload is JSON (schema versioning required)

### Aggregates
- Derive state by replaying events (never mutate state directly)
- Optimistic locking with version number (conflict detection)
- 1 aggregate = 1 transaction (don't update multiple aggregates simultaneously)

### Projections (Read Model)
- Build read data asynchronously from events
- Projections can be deleted and rebuilt (events are the truth)
- Separate write and read models with CQRS
Enter fullscreen mode Exit fullscreen mode

Generated Event Sourcing Implementation

// src/eventSourcing/eventStore.ts

export class EventStore {
  async append(aggregateId: string, aggregateType: string, events: OrderEvent[], expectedVersion: number): Promise<void> {
    await prisma.$transaction(async (tx) => {
      const currentVersion = await tx.$queryRaw<Array<{ max: number }>>`
        SELECT COALESCE(MAX(version), 0) as max FROM events
        WHERE aggregate_id = ${aggregateId} FOR UPDATE
      `;

      if (currentVersion[0].max !== expectedVersion) {
        throw new OptimisticLockError(`Expected version ${expectedVersion}, got ${currentVersion[0].max}`);
      }

      let version = expectedVersion;
      for (const event of events) {
        version++;
        await tx.event.create({
          data: { aggregateId, aggregateType, version, type: event.type, data: event.data, occurredAt: new Date() },
        });
      }
    });
  }

  async getEvents(aggregateId: string): Promise<StoredEvent[]> {
    return prisma.event.findMany({ where: { aggregateId }, orderBy: { version: 'asc' } });
  }
}
Enter fullscreen mode Exit fullscreen mode
// src/eventSourcing/orderAggregate.ts

export class OrderAggregate {
  static fromEvents(aggregateId: string, events: StoredEvent[]): OrderAggregate {
    const state = events.reduce((acc, event) => OrderAggregate.applyEvent(acc, event), {
      id: aggregateId, userId: '', items: [], status: 'pending', version: 0,
    });
    return new OrderAggregate(state);
  }

  private static applyEvent(state: OrderState, event: StoredEvent): OrderState {
    switch (event.type) {
      case 'OrderPlaced': return { ...state, ...event.data, version: event.version };
      case 'OrderCancelled': return { ...state, status: 'cancelled', version: event.version };
      case 'OrderShipped': return { ...state, status: 'shipped', version: event.version };
      default: return state;
    }
  }

  cancel(reason: string, cancelledBy: string): void {
    if (this.state.status !== 'pending') throw new BusinessError('Cannot cancel order in current status');
    this.pendingEvents.push({ type: 'OrderCancelled', data: { reason, cancelledBy } });
    this.state = { ...this.state, status: 'cancelled' };
  }

  async save(): Promise<void> {
    await eventStore.append(this.state.id, 'Order', this.pendingEvents, this.state.version);
    this.pendingEvents = [];
  }
}
Enter fullscreen mode Exit fullscreen mode

Projection (Read Model)

export async function rebuildOrderProjection(): Promise<void> {
  const checkpoint = await prisma.projectionCheckpoint.findUnique({ where: { name: 'order_projection' } });
  const events = await prisma.event.findMany({
    where: { aggregateType: 'Order', id: { gt: checkpoint?.lastEventId ?? 0 } },
    orderBy: { id: 'asc' }, take: 100,
  });

  for (const event of events) {
    if (event.type === 'OrderPlaced') {
      await prisma.orderView.create({ data: { id: event.aggregateId, ...event.data } });
    } else if (event.type === 'OrderCancelled') {
      await prisma.orderView.update({ where: { id: event.aggregateId }, data: { status: 'cancelled' } });
    }
  }

  if (events.length > 0) {
    await prisma.projectionCheckpoint.upsert({
      where: { name: 'order_projection' },
      create: { name: 'order_projection', lastEventId: events.at(-1)!.id },
      update: { lastEventId: events.at(-1)!.id },
    });
  }
}
Enter fullscreen mode Exit fullscreen mode

Summary

Design Event Sourcing with Claude Code:

  1. CLAUDE.md — past-tense event naming, append-only, aggregate boundaries
  2. Optimistic locking with version check (prevent concurrent aggregate writes)
  3. applyEvent rebuilds state from events (can return to any point in time)
  4. Projections build denormalized read views from events asynchronously

Review Event Sourcing designs with **Code Review Pack (¥980)* using /code-review at prompt-works.jp*

myouga (@myougatheaxo) — Axolotl VTuber.

Top comments (0)