Install any skill in seconds. Free to start, no credit card required.
Get Started Free →Design and implement event stores for event-sourced systems. Use when building event sourcing infrastructure, implementing event persistence, projections, snapshotting, or CQRS patterns.
.claude/skills/leoyeai-event-store/SKILL.md| Test case | Without → With | Effect | Δ tokens | Δ turns |
|---|---|---|---|---|
| case-04 | ✗→✓ | ▲ Improved | 142% | 0% |
| case-01 | ✗→✓ | ▲ Improved | 79% | 0% |
| case-12 | ✓→✓ | = Same ✓ | 208% | 0% |
| case-02 | ✓→✓ | = Same ✓ | 112% | 0% |
| case-03 | ✓→✓ | = Same ✓ | 221% | 0% |
Guide to designing event stores for event-sourced applications — covering event schemas, projections, snapshotting, and CQRS integration.
┌─────────────────────────────────────────────────────┐
│ Event Store │
├─────────────────────────────────────────────────────┤
│ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐ │
│ │ Stream 1 │ │ Stream 2 │ │ Stream 3 │ │
│ │ (Aggregate) │ │ (Aggregate) │ │ (Aggregate) │ │
│ ├─────────────┤ ├─────────────┤ ├─────────────┤ │
│ │ Event 1 │ │ Event 1 │ │ Event 1 │ │
│ │ Event 2 │ │ Event 2 │ │ Event 2 │ │
│ │ Event 3 │ │ ... │ │ Event 3 │ │
│ │ ... │ │ │ │ Event 4 │ │
│ └─────────────┘ └─────────────┘ └─────────────┘ │
├─────────────────────────────────────────────────────┤
│ Global Position: 1 → 2 → 3 → 4 → 5 → 6 → ... │
└─────────────────────────────────────────────────────┘| Requirement | Description | | ----------------- | ---------------------------------- | | Append-only | Events are immutable, only appends | | Ordered | Per-stream and global ordering | | Versioned | Optimistic concurrency control | | Subscriptions | Real-time event notifications | | Idempotent | Handle duplicate writes safely |
| Technology | Best For | Limitations | | ---------------- | ----------------------- | -------------------------------- | | EventStoreDB | Pure event sourcing | Single-purpose | | PostgreSQL | Existing Postgres stack | Manual implementation | | Kafka | High-throughput streams | Not ideal for per-stream queries | | DynamoDB | Serverless, AWS-native | Query limitations |
Events are the source of truth. Well-designed schemas ensure long-term evolvability.
json{ "event_id": "uuid", "stream_id": "Order-abc123", "event_type": "OrderPlaced", "version": 1, "schema_version": 1, "data": { "customer_id": "cust-1", "total_cents": 5000 }, "metadata": { "correlation_id": "req-xyz", "causation_id": "evt-prev", "user_id": "user-1", "timestamp": "2025-01-15T10:30:00Z" }, "global_position": 42 }
OrderPlacedV2 when the schema changes materiallysqlCREATE TABLE events ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), stream_id VARCHAR(255) NOT NULL, stream_type VARCHAR(255) NOT NULL, event_type VARCHAR(255) NOT NULL, event_data JSONB NOT NULL, metadata JSONB DEFAULT '{}', version BIGINT NOT NULL, global_position BIGSERIAL, created_at TIMESTAMPTZ DEFAULT NOW(), CONSTRAINT unique_stream_version UNIQUE (stream_id, version) ); CREATE INDEX idx_events_stream ON events(stream_id, version); CREATE INDEX idx_events_global ON events(global_position); CREATE INDEX idx_events_type ON events(event_type); CREATE TABLE snapshots ( stream_id VARCHAR(255) PRIMARY KEY, stream_type VARCHAR(255) NOT NULL, snapshot_data JSONB NOT NULL, version BIGINT NOT NULL, created_at TIMESTAMPTZ DEFAULT NOW() ); CREATE TABLE subscription_checkpoints ( subscription_id VARCHAR(255) PRIMARY KEY, last_position BIGINT NOT NULL DEFAULT 0, updated_at TIMESTAMPTZ DEFAULT NOW() );
python@dataclass class Event: stream_id: str event_type: str data: dict metadata: dict = field(default_factory=dict) event_id: UUID = field(default_factory=uuid4) version: int | None = None global_position: int | None = None class EventStore: # backed by PostgreSQL schema above def __init__(self, pool: asyncpg.Pool): self.pool = pool async def append(self, stream_id: str, stream_type: str, events: list[Event], expected_version: int | None = None) -> list[Event]: """Append events with optimistic concurrency control.""" async with self.pool.acquire() as conn: async with conn.transaction(): if expected_version is not None: current = await conn.fetchval( "SELECT MAX(version) FROM events " "WHERE stream_id = $1", stream_id ) or 0 if current != expected_version: raise ConcurrencyError( f"Expected {expected_version}, got {current}" ) start = await conn.fetchval( "SELECT COALESCE(MAX(version), 0) + 1 " "FROM events WHERE stream_id = $1", stream_id ) for i, evt in enumerate(events): evt.version = start + i row = await conn.fetchrow( "INSERT INTO events (id, stream_id, stream_type, " "event_type, event_data, metadata, version) " "VALUES ($1,$2,$3,$4,$5,$6,$7) " "RETURNING global_position", evt.event_id, stream_id, stream_type, evt.event_type, json.dumps(evt.data), json.dumps(evt.metadata), evt.version, ) evt.global_position = row["global_position"] return events async def read_stream(self, stream_id: str, from_version: int = 0) -> list[Event]: """Read events for a single stream.""" async with self.pool.acquire() as conn: rows = await conn.fetch( "SELECT * FROM events WHERE stream_id = $1 " "AND version >= $2 ORDER BY version", stream_id, from_version, ) return [self._to_event(r) for r in rows] async def read_all(self, from_position: int = 0, limit: int = 1000) -> list[Event]: """Read global event stream for projections / subscriptions.""" async with self.pool.acquire() as conn: rows = await conn.fetch( "SELECT * FROM events WHERE global_position > $1 " "ORDER BY global_position LIMIT $2", from_position, limit, ) return [self._to_event(r) for r in rows]
Projections build read-optimised views by replaying events. They are the "Q" side of CQRS.
pythonclass OrderSummaryProjection: def __init__(self, db, event_store: EventStore): self.db = db self.store = event_store async def run(self, batch_size: int = 100): position = await self._load_checkpoint() while True: events = await self.store.read_all(position, batch_size) if not events: await asyncio.sleep(1) continue for evt in events: await self._apply(evt) position = evt.global_position await self._save_checkpoint(position) async def _apply(self, event: Event): match event.event_type: case "OrderPlaced": await self.db.execute( "INSERT INTO order_summaries (id, customer, total, status) " "VALUES ($1,$2,$3,'placed')", event.data["order_id"], event.data["customer_id"], event.data["total_cents"], ) case "OrderShipped": await self.db.execute( "UPDATE order_summaries SET status='shipped' " "WHERE id=$1", event.data["order_id"], )
Snapshots accelerate aggregate rehydration by caching state at a known version.
Use when streams exceed ~100 events, aggregates have expensive rehydration, or on a cadence (e.g., every 50 events).
pythonclass SnapshottedRepository: def __init__(self, event_store: EventStore, pool): self.store = event_store self.pool = pool async def load(self, stream_id: str) -> Aggregate: # 1. Try loading snapshot snap = await self._load_snapshot(stream_id) from_version = 0 aggregate = Aggregate(stream_id) if snap: aggregate.restore(snap["data"]) from_version = snap["version"] + 1 # 2. Replay events after snapshot events = await self.store.read_stream(stream_id, from_version) for evt in events: aggregate.apply(evt) # 3. Snapshot if too many events replayed if len(events) > 50: await self._save_snapshot( stream_id, aggregate.snapshot(), aggregate.version ) return aggregate
CQRS separates the write model (commands → events) from the read model (projections).
Commands ──► Aggregate ──► Event Store ──► Projections ──► Query API
(write) (domain) (append) (build) (read)pythonclass PlaceOrderHandler: def __init__(self, event_store: EventStore): self.store = event_store async def handle(self, cmd: PlaceOrderCommand): # Load aggregate from events events = await self.store.read_stream(f"Order-{cmd.order_id}") order = Order.reconstitute(events) # Execute command — validates and produces new events new_events = order.place(cmd.customer_id, cmd.items) # Persist with concurrency check await self.store.append( f"Order-{cmd.order_id}", "Order", new_events, expected_version=order.version, )
pythonfrom esdbclient import EventStoreDBClient, NewEvent, StreamState import json client = EventStoreDBClient(uri="esdb://localhost:2113?tls=false") def append_events(stream_name: str, events: list, expected_revision=None): new_events = [ NewEvent( type=event['type'], data=json.dumps(event['data']).encode(), metadata=json.dumps(event.get('metadata', {})).encode() ) for event in events ] state = (StreamState.ANY if expected_revision is None else StreamState.NO_STREAM if expected_revision == -1 else expected_revision) return client.append_to_stream(stream_name, new_events, current_version=state) def read_stream(stream_name: str, from_revision: int = 0): return [ {'type': e.type, 'data': json.loads(e.data), 'stream_position': e.stream_position} for e in client.get_stream(stream_name, stream_position=from_revision) ] # Category projection: read all events for Order-* streams def read_category(category: str): return read_stream(f"$ce-{category}")
pythonimport boto3 from boto3.dynamodb.conditions import Key from datetime import datetime import json, uuid class DynamoEventStore: def __init__(self, table_name: str): self.table = boto3.resource('dynamodb').Table(table_name) def append(self, stream_id: str, events: list, expected_version: int = 0): with self.table.batch_writer() as batch: for i, event in enumerate(events): version = expected_version + i + 1 batch.put_item(Item={ 'PK': f"STREAM#{stream_id}", 'SK': f"VERSION#{version:020d}", 'GSI1PK': 'EVENTS', 'GSI1SK': datetime.utcnow().isoformat(), 'event_id': str(uuid.uuid4()), 'event_type': event['type'], 'event_data': json.dumps(event['data']), 'version': version, }) def read_stream(self, stream_id: str, from_version: int = 0): resp = self.table.query( KeyConditionExpression= Key('PK').eq(f"STREAM#{stream_id}") & Key('SK').gte(f"VERSION#{from_version:020d}") ) return [ {'event_type': item['event_type'], 'data': json.loads(item['event_data']), 'version': item['version']} for item in resp['Items'] ]
DynamoDB table design: PK=STREAM#{id}, SK=VERSION#{version}, GSI1 for global ordering.
{Type}-{id} — e.g., Order-abc123| Case | Status | Duration (ms) | Turns | Tokens | Tool calls | ||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| Without | With | Δ | Without | With | Δ | Without | With | Δ | Without | With | Δ | ||
case-12 | pass→pass | 13,116 | 13,449 | +3% | 1 | 1 | 0% | 1,997 | 6,159 | +208% | 0 | 0 | — |
case-02 | pass→pass | 16,629 | 8,530 | -49% | 1 | 1 | 0% | 2,706 | 5,725 | +112% | 0 | 0 | — |
case-03 | pass→pass | 13,999 | 16,587 | +18% | 1 | 1 | 0% | 2,230 | 7,160 | +221% | 0 | 0 | — |
case-04 | fail→pass | 16,176 | 17,919 | +11% | 1 | 1 | 0% | 2,761 | 6,689 | +142% | 0 | 0 | — |
case-05 | pass→pass | 13,309 | 12,692 | -5% | 1 | 1 | 0% | 2,285 | 6,210 | +172% | 0 | 0 | — |
case-06 | pass→pass | 16,407 | 13,633 | -17% | 1 | 1 | 0% | 2,921 | 6,118 | +109% | 0 | 0 | — |
case-11 | pass→pass | 16,138 | 11,641 | -28% | 1 | 1 | 0% | 2,591 | 6,028 | +133% | 0 | 0 | — |
case-01 | fail→pass | 28,079 | 33,031 | +18% | 1 | 1 | 0% | 5,649 | 10,103 | +79% | 0 | 0 | — |
case-07 | pass→pass | 23,990 | 17,943 | -25% | 1 | 1 | 0% | 4,088 | 7,197 | +76% | 0 | 0 | — |
case-08 | pass→pass | 18,235 | 15,431 | -15% | 1 | 1 | 0% | 3,185 | 6,887 | +116% | 0 | 0 | — |
case-09 | fail→fail | 16,215 | 19,808 | +22% | 1 | 1 | 0% | 2,953 | 7,215 | +144% | 0 | 0 | — |
case-10 | pass→pass | 11,992 | 13,133 | +10% | 1 | 1 | 0% | 2,186 | 6,649 | +204% | 0 | 0 | — |
case-13 | pass→pass | 21,466 | 23,823 | +11% | 1 | 1 | 0% | 3,072 | 7,311 | +138% | 0 | 0 | — |
case-14 | pass→pass | 12,940 | 17,055 | +32% | 1 | 1 | 0% | 2,039 | 6,639 | +226% | 0 | 0 | — |
case-15 | pass→pass | 13,905 | 11,585 | -17% | 1 | 1 | 0% | 2,100 | 5,723 | +173% | 0 | 0 | — |
case-16 | pass→pass | 17,028 | 16,038 | -6% | 1 | 1 | 0% | 2,681 | 6,883 | +157% | 0 | 0 | — |
case-17 | pass→pass | 12,614 | 6,374 | -49% | 1 | 1 | 0% | 1,911 | 4,877 | +155% | 0 | 0 | — |
case-18 | pass→pass | 17,638 | 17,554 | -0% | 1 | 1 | 0% | 2,555 | 6,596 | +158% | 0 | 0 | — |
case-19 | pass→pass | 10,504 | 10,807 | +3% | 1 | 1 | 0% | 2,152 | 5,889 | +174% | 0 | 0 | — |
case-20 | pass→pass | 14,004 | 18,042 | +29% | 1 | 1 | 0% | 2,271 | 6,660 | +193% | 0 | 0 | — |
case-21 | pass→pass | 16,822 | 15,277 | -9% | 1 | 1 | 0% | 2,703 | 6,554 | +142% | 0 | 0 | — |
case-22 | pass→pass | 18,248 | 14,266 | -22% | 1 | 1 | 0% | 3,475 | 6,530 | +88% | 0 | 0 | — |
case-23 | pass→pass | 18,157 | 15,867 | -13% | 1 | 1 | 0% | 3,370 | 6,991 | +107% | 0 | 0 | — |
DecimalAI ran this skill against gemini-3.6-flash twice over the same eval suite — once with the skill loaded and once without — and compared the two runs case by case. 23 cases were attempted. The headline lift of +9 percentage points is the difference between those two pass rates over the 23 comparable cases.
Without the skill loaded, the model failed this case. With it loaded, the same prompt on the same model passed. This is one improved case from the latest verified run; every case, including any that regressed, is in the table above.
Other measured skills in the registry, with their headline benchmark lift.