brainstorming
brainstorming
You MUST use this before any creative work - creating features, building components, adding functionality, modifying behavior, or planning anything new, in software or out of it (a talk, a business, a renovation).
backend-event-sourcingUse this skill when the user says 'event sourcing', 'event store', 'event stream', 'event sourced', 'rehydrate from events', 'event replay', 'projection rebuild', 'event log', 'append-only log', 'event history'. This skill enforces: events as the single source of truth, current state derived from event replay, append-only event store, immutable events, even…
Independent GitHub snapshot · Automated classification · Original documentation governs
Store state changes as an append-only sequence of events. Current state is derived by replaying events. Every event is immutable, auditable, and replayable.
Exact user phrases: "event sourcing", "event store", "event stream", "event sourced", "rehydrate from events", "event replay", "projection rebuild", "append-only log", "event history", "event store pattern".
Event sourcing design as text. No file unless requested.
Aggregate: {name}
Events: [{EventName, version, key fields}]
Current state: {derived by replay}
Projections: [{name, rebuild from}]
Per aggregate: 8 lines. Full design: 35 lines.
Does the full history of changes matter?
├── Yes → Audit, compliance, dispute resolution → Event Sourcing candidate
└── No → Is state derivation logic complex?
├── Yes → Event Sourcing simplifies temporal queries
└── No → Are multiple projections of the same data needed?
├── Yes → Event Sourcing enables flexible projections
└── No → Simple CRUD is sufficient — skip Event Sourcing
What infrastructure is already available?
├── PostgreSQL → Great event store (JSONB for event data, reliable, transactional)
├── EventStoreDB → Purpose-built for event sourcing (projections built-in)
├── DynamoDB → Serverless, single-table design for event streams
└── Kafka → Durable log, but limited query capabilities for aggregate reconstruction
How many events per aggregate?
├── < 100 → No snapshots needed
├── 100-1000 → Snapshot every 50 events
├── 1000-10000 → Snapshot every 100 events
└── > 10000 → Snapshot every 500 events, consider splitting aggregate
Choose aggregates where:
interface OrderPlaced {
eventType: 'OrderPlaced';
version: 1;
data: { orderId: string; customerId: string; items: OrderItem[]; total: number };
metadata: { eventId: string; aggregateId: string; version: number; timestamp: Date; correlationId: string };
}
interface PaymentReceived {
eventType: 'PaymentReceived';
version: 1;
data: { orderId: string; amount: number; paymentMethod: string; transactionId: string };
}
interface OrderShipped {
eventType: 'OrderShipped';
version: 1;
data: { orderId: string; trackingNumber: string; carrier: string };
}
class OrderAggregate {
private state: OrderState = { status: 'pending', items: [], total: 0, payments: [] };
static loadFromHistory(events: Event[]): OrderAggregate {
const aggregate = new OrderAggregate();
for (const event of events) {
aggregate.apply(event);
}
return aggregate;
}
placeOrder(command: PlaceOrderCommand): OrderPlacedEvent {
if (this.state.status !== 'pending') throw new Error('Order already placed');
return { eventType: 'OrderPlaced', version: 1, data: { ...command }, metadata: this.createMetadata() };
}
applyEvent(event: OrderPlacedEvent): void {
this.state = { status: 'placed', items: event.data.items, total: event.data.total, payments: [] };
}
applyEvent(event: PaymentReceivedEvent): void {
this.state.payments.push({ amount: event.data.amount, method: event.data.paymentMethod });
if (this.totalPaid() >= this.state.total) {
this.state.status = 'paid';
}
}
}
class OrderAggregate:
def __init__(self):
self.state = OrderState(status="pending", items=[], total=0, payments=[])
@classmethod
def load_from_history(cls, events: list[Event]) -> "OrderAggregate":
aggregate = cls()
for event in events:
aggregate.apply(event)
return aggregate
def place_order(self, command: PlaceOrderCommand) -> OrderPlacedEvent:
if self.state.status != "pending":
raise BusinessRuleError("Order already placed")
return OrderPlacedEvent(data={...})
def apply(self, event: Event) -> None:
if event.event_type == "OrderPlaced":
self.state = OrderState(status="placed", items=event.data["items"], ...)
CREATE TABLE event_store (
id BIGSERIAL,
aggregate_type VARCHAR(100) NOT NULL,
aggregate_id UUID NOT NULL,
version INTEGER NOT NULL,
event_type VARCHAR(200) NOT NULL,
event_data JSONB NOT NULL,
metadata JSONB NOT NULL,
created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW(),
PRIMARY KEY (aggregate_type, aggregate_id, version)
);
CREATE INDEX idx_event_store_aggregate ON event_store(aggregate_type, aggregate_id);
CREATE INDEX idx_event_store_type ON event_store(event_type);
CREATE INDEX idx_event_store_created ON event_store(created_at);
class OrderListProjection {
constructor(private readDb: IReadRepository) {}
async onOrderPlaced(event: OrderPlacedEvent): Promise<void> {
await this.readDb.insert('order_summaries', {
id: event.data.orderId,
customerId: event.data.customerId,
itemCount: event.data.items.length,
total: event.data.total,
status: 'placed',
createdAt: event.metadata.timestamp,
});
}
async onOrderShipped(event: OrderShippedEvent): Promise<void> {
await this.readDb.update('order_summaries', event.data.orderId, {
status: 'shipped',
trackingNumber: event.data.trackingNumber,
});
}
async rebuild(): Promise<void> {
await this.readDb.clear('order_summaries');
const events = await eventStore.getEventsByType('OrderPlaced', 'OrderShipped');
for (const event of events) {
if (event.eventType === 'OrderPlaced') await this.onOrderPlaced(event);
if (event.eventType === 'OrderShipped') await this.onOrderShipped(event);
}
}
}
For aggregates with thousands of events, take periodic snapshots:
async function saveSnapshot(aggregateId: string, aggregate: OrderAggregate, version: number): Promise<void> {
await snapshotStore.save(aggregateId, aggregate.getState(), version);
}
async function loadWithSnapshot(aggregateId: string): Promise<OrderAggregate> {
const snapshot = await snapshotStore.getLatest(aggregateId);
const events = await eventStore.getEventsSince(aggregateId, snapshot?.version ?? 0);
const aggregate = snapshot
? OrderAggregate.fromSnapshot(snapshot.state)
: new OrderAggregate();
for (const event of events) {
aggregate.applyEvent(event);
}
return aggregate;
}
async function getOrderStateAtTime(orderId: string, timestamp: Date): Promise<OrderState> {
const events = await eventStore.getAggregateEventsUntil('order', orderId, timestamp);
const aggregate = OrderAggregate.loadFromHistory(events);
return aggregate.getState();
}
class OrderRepository {
async findById(id: OrderId): Promise<OrderAggregate> {
const events = await this.eventStore.getAggregateEvents('order', id);
if (events.length === 0) throw new NotFoundError(`Order ${id} not found`);
return OrderAggregate.loadFromHistory(events);
}
async save(aggregate: OrderAggregate): Promise<void> {
const newEvents = aggregate.getUncommittedEvents();
const expectedVersion = aggregate.getVersion() - newEvents.length;
await this.eventStore.append('order', aggregate.id, newEvents, expectedVersion);
aggregate.markEventsAsCommitted();
}
}
class EventStore {
async append(aggregateType: string, aggregateId: string, events: Event[], expectedVersion: number): Promise<void> {
const result = await this.db.query(
`INSERT INTO event_store (aggregate_type, aggregate_id, version, event_type, event_data, metadata)
SELECT $1, $2, $3 + generate_series, $4, $5, $6
FROM (SELECT generate_series(1, $7) AS generate_series) AS gs`,
[aggregateType, aggregateId, expectedVersion, ...]
);
// If another transaction inserted with the same version, the PK constraint will fail
// This gives us optimistic concurrency control
}
}
type Event = OrderPlacedV1 | OrderPlacedV2 | PaymentReceivedV1;
interface OrderPlacedV1 {
eventType: 'OrderPlaced';
version: 1;
data: { orderId: string; customerId: string; total: number };
}
interface OrderPlacedV2 {
eventType: 'OrderPlaced';
version: 2;
data: { orderId: string; customerId: string; items: OrderItem[]; total: number; currency: string };
}
// Upcaster: converts V1 to V2 on read
function upcastOrderPlaced(event: OrderPlacedV1): OrderPlacedV2 {
return {
eventType: 'OrderPlaced',
version: 2,
data: {
...event.data,
items: [], // V1 didn't have items
currency: 'USD', // default
},
};
}
| Snapshot Frequency | Aggregate Size | Load Time | Storage Overhead |
|---|---|---|---|
| Every 10 events | Small | Fast | High |
| Every 50 events | Medium | Medium | Medium |
| Every 100 events | Large | Slow | Low |
// Never delete or modify events — use compensating events instead
interface OrderCancelled {
eventType: 'OrderCancelled';
version: 1;
data: { orderId: string; reason: string };
}
No artifact produced. Next skill: cqrs-patterns — to separate read/write models for event-sourced aggregates. Carry forward: event definitions, aggregate design, projection rebuild strategy.
from typing import Dict, List, Optional, Any
from dataclasses import dataclass, field
from datetime import datetime
import json
import uuid
@dataclass
class DomainEvent:
event_id: str
aggregate_id: str
event_type: str
data: Dict
version: int
timestamp: str
correlation_id: str
causation_id: str
class EventStore:
def __init__(self):
self.events: List[DomainEvent] = []
self.snapshots: Dict[str, Dict] = {}
def append(self, aggregate_id: str, events: List[Dict], expected_version: int) -> bool:
current = self._get_current_version(aggregate_id)
if current != expected_version:
raise ConcurrencyError(f"Expected version {expected_version}, got {current}")
for event_data in events:
event = DomainEvent(
event_id=str(uuid.uuid4()),
aggregate_id=aggregate_id,
event_type=event_data["type"],
data=event_data["data"],
version=current + 1,
timestamp=datetime.utcnow().isoformat() + "Z",
correlation_id=event_data.get("correlation_id", ""),
causation_id=event_data.get("causation_id", ""),
)
self.events.append(event)
current += 1
return True
def get_events(self, aggregate_id: str, from_version: int = 0) -> List[DomainEvent]:
return [e for e in self.events if e.aggregate_id == aggregate_id and e.version > from_version]
def get_all_events(self, event_types: Optional[List[str]] = None) -> List[DomainEvent]:
if not event_types:
return self.events
return [e for e in self.events if e.event_type in event_types]
def save_snapshot(self, aggregate_id: str, state: Dict, version: int):
self.snapshots[aggregate_id] = {"state": state, "version": version, "timestamp": datetime.utcnow().isoformat()}
def get_snapshot(self, aggregate_id: str) -> Optional[Dict]:
return self.snapshots.get(aggregate_id)
def _get_current_version(self, aggregate_id: str) -> int:
events = self.get_events(aggregate_id)
return max((e.version for e in events), default=0)
def rebuild_aggregate(self, aggregate_id: str, apply_fn) -> Any:
snapshot = self.get_snapshot(aggregate_id)
if snapshot:
state = snapshot["state"]
from_version = snapshot["version"]
else:
state = {}
from_version = 0
events = self.get_events(aggregate_id, from_version)
for event in events:
state = apply_fn(state, event)
return state
class ConcurrencyError(Exception):
pass
class ProjectionRebuilder:
def __init__(self, event_store: EventStore):
self.event_store = event_store
def rebuild(self, projection_name: str, event_types: List[str], apply_fn) -> Any:
state = {}
events = self.event_store.get_all_events(event_types)
for event in sorted(events, key=lambda e: e.timestamp):
state = apply_fn(state, event)
return state
When to take snapshots?
├── After N events (N=100 for most, N=1000 for simple)
├── When aggregate rebuild time exceeds 100ms
├── On schedule (every hour for high-traffic aggregates)
└── After specific business events (e.g., order completed, account closed)
| Anti-Pattern | Why It Fails | Correct Approach |
|---|---|---|
| Events as DTOs reflecting DB columns | No business meaning | Events capture business intent (OrderPlaced, not StatusSetToPlaced) |
| Deleting events for data privacy | Breaks event stream integrity | Anonymize PII in events, don't delete |
| No upcasting strategy | Old events break new code | Upcast on read, keep old events unchanged |
| Projections with business logic | Duplicating aggregate rules | Projections are data transformations, aggregates have business logic |
| Too many event types | Event explosion, hard to reason about | Group related changes, use event metadata for details |