
Event Sourcing
Implement an in-memory or extendable TypeScript event store with optimistic concurrency, aggregates, and bank-account style domain events.
Overview
Event Sourcing is an agent skill most often used in Build (also Validate scope, Ship review) that provides TypeScript EventStore and aggregate patterns with optimistic concurrency.
Install
npx skills add https://github.com/aj-geddes/useful-ai-prompts --skill event-sourcingWhat is this skill?
- TypeScript EventStore with appendEvents, getEvents, and getCurrentVersion
- Optimistic concurrency via expectedVersion on append
- DomainEvent shape: id, aggregateId, aggregateType, eventType, data, metadata with version and timestamp
- Bank Account aggregate example for deposit/withdraw style state rebuild
- crypto.randomUUID for event ids in the reference implementation
Adoption & trust: 736 installs on skills.sh; 250 GitHub stars; 3/3 security scanners passed (skills.sh audits).
What problem does it solve?
You need a clear event-sourced persistence pattern in TypeScript but only have ad-hoc CRUD examples.
Who is it for?
Indie API builders adopting event sourcing for audit trails or domain-driven modules before wiring a real database.
Skip if: Teams wanting full CQRS projection pipelines, production Kafka ops, or a no-code persistence product out of the box.
When should I use this skill?
When implementing event-sourced aggregates, optimistic concurrency, or TypeScript domain event storage patterns.
What do I get? / Deliverables
You implement append-only DomainEvent storage, versioned metadata, and aggregate replay starting from the provided EventStore and bank account sample.
- EventStore class implementation
- DomainEvent and aggregate interfaces
- Example bank account event handlers
Recommended Skills
Journey fit
Spans multiple journey phases - primary shelf plus alternate fits below.
Build/backend is the canonical shelf because the skill centers on EventStore and aggregate code patterns agents paste into services. Backend fits domain events, append/get streams, version checks, and aggregate replay—not UI or DevOps.
Where it fits
Decide whether order and payment flows need append-only audit history before choosing CRUD versus event sourcing.
Generate EventStore append/get methods and BankAccount aggregate handlers in your API service.
Verify concurrency conflict throws and version monotonicity before connecting Postgres or outbox publishers.
How it compares
Use as procedural TypeScript templates instead of generic REST CRUD snippets when you need append-only history.
Common Questions / FAQ
Who is event-sourcing for?
Solo builders and small teams implementing TypeScript backends who want event store interfaces, concurrency guards, and aggregate examples.
When should I use event-sourcing?
In Validate when scoping CQRS/event logs for your domain; in Build when coding EventStore and aggregates; in Ship when reviewing concurrency conflict handling before production storage.
Is event-sourcing safe to install?
It is reference code and prompts—review the Security Audits panel on this page and treat in-memory samples as starting points, not production storage.
SKILL.md
READMESKILL.md - Event Sourcing
# Event Store (TypeScript) ## Event Store (TypeScript) ```typescript interface DomainEvent { id: string; aggregateId: string; aggregateType: string; eventType: string; data: any; metadata: { userId?: string; timestamp: number; version: number; }; } interface Aggregate { id: string; version: number; } class EventStore { private events: DomainEvent[] = []; async appendEvents( aggregateId: string, expectedVersion: number, events: Omit<DomainEvent, "id" | "metadata">[], ): Promise<void> { // Optimistic concurrency check const currentVersion = await this.getCurrentVersion(aggregateId); if (currentVersion !== expectedVersion) { throw new Error("Concurrency conflict"); } const newEvents = events.map((event, index) => ({ ...event, id: crypto.randomUUID(), metadata: { timestamp: Date.now(), version: expectedVersion + index + 1, }, })); this.events.push(...newEvents); } async getEvents(aggregateId: string): Promise<DomainEvent[]> { return this.events .filter((e) => e.aggregateId === aggregateId) .sort((a, b) => a.metadata.version - b.metadata.version); } async getCurrentVersion(aggregateId: string): Promise<number> { const events = await this.getEvents(aggregateId); return events.length > 0 ? events[events.length - 1].metadata.version : 0; } } // Bank Account Aggregate interface BankAccountState { id: string; balance: number; isOpen: boolean; version: number; } class BankAccount implements Aggregate { id: string; version: number; private balance: number = 0; private isOpen: boolean = false; private uncommittedEvents: DomainEvent[] = []; constructor(id: string) { this.id = id; this.version = 0; } // Commands open(initialDeposit: number): void { if (this.isOpen) { throw new Error("Account already open"); } this.applyEvent({ eventType: "AccountOpened", data: { initialDeposit }, }); } deposit(amount: number): void { if (!this.isOpen) { throw new Error("Account not open"); } if (amount <= 0) { throw new Error("Amount must be positive"); } this.applyEvent({ eventType: "MoneyDeposited", data: { amount }, }); } withdraw(amount: number): void { if (!this.isOpen) { throw new Error("Account not open"); } if (amount <= 0) { throw new Error("Amount must be positive"); } if (this.balance < amount) { throw new Error("Insufficient funds"); } this.applyEvent({ eventType: "MoneyWithdrawn", data: { amount }, }); } close(): void { if (!this.isOpen) { throw new Error("Account not open"); } if (this.balance > 0) { throw new Error("Cannot close account with positive balance"); } this.applyEvent({ eventType: "AccountClosed", data: {}, }); } // Event Application private applyEvent(event: Partial<DomainEvent>): void { const fullEvent: any = { aggregateId: this.id, aggregateType: "BankAccount", ...event, }; this.apply(fullEvent); this.uncommittedEvents.push(fullEvent); } apply(event: DomainEvent): void { switch (event.eventType) { case "AccountOpened": this.isOpen = true; this.balance = event.data.initialDeposit; break; case "MoneyDeposited": this.balance += event.data.amount; break; case "MoneyWithdrawn": this.balance -= event.data.amount; break; case "AccountClosed": this.isOpen = false; break; } if (event.metadata) { this.version = event.metadata.version; } } getUncommittedEvents(): DomainEvent[] { return this.uncommittedEvents; } clearUncommittedEvents(): void { this.uncommittedEvents = []; } getState(): BankAccountState { return { id: this.id,