Files
suaveplan/openspec/archive/2026-04-24-enterprise-event-sourcing/proposal.md
2026-05-27 23:29:53 +01:00

9.8 KiB

OpenSpec Change: enterprise-event-sourcing

  • Change ID: enterprise-event-sourcing
  • Package: @suaveplan/event-sourcing
  • Category: universal
  • Target path: universal/events/event-sourcing
  • Status: PROPOSED
  • Date: 2026-04-24

Summary

Deliver @suaveplan/event-sourcing on the genesis branch from scratch at enterprise quality. The package provides event-sourcing patterns for building aggregate-driven domain models: a typed Aggregate base class with apply/raise lifecycle, an EventStore interface with in-memory and pluggable-adapter implementations, optimistic concurrency via version vectors, a Projection builder for read-model construction, snapshotting support, @suaveplan/diff delta integration for efficient event payload storage, @suaveplan/events integration for publish-after-commit, structured errors, and OTel instrumentation.


Current-state audit (genesis)

The package does not exist on genesis yet — fresh build. No prior source, no test results, no coverage data.


Available workspace deps on genesis

Package Role
@suaveplan/events (workspace:^) Publish committed domain events to subscribers after persistence
@suaveplan/diff (workspace:^) JSON Patch delta encoding for efficient event payload storage and upcasting
@suaveplan/error (workspace:^) Base class for EventSourcingError; defineCodes for ES.* codes
@suaveplan/types (workspace:^) LoggerLike, Clock, IdGenerator, EventBusLike injection interfaces
zod (catalog:forms) Zod schemas for event envelope, snapshot, and options types
@opentelemetry/api (catalog:observability) Spans, counters, histograms on append, load, project operations
tslib (catalog:build) Runtime helpers

Dev deps: vitest, @vitest/coverage-istanbul, fast-check, mitata (all catalog:testing).


Public API surface

Domain event

interface DomainEvent<TPayload = unknown> {
  id: string
  aggregateId: string
  aggregateType: string
  type: string
  version: number           // sequence number within the aggregate stream
  payload: TPayload
  metadata: EventMetadata
  occurredAt: Date
}

interface EventMetadata {
  causationId?: string
  correlationId?: string
  actor?: string
  [key: string]: unknown
}

Aggregate base class

abstract class Aggregate<TState> {
  readonly id: string
  protected state: TState
  readonly version: number

  // Raise a domain event — applies it immediately and queues it for commit
  protected raise<TPayload>(type: string, payload: TPayload): void

  // Apply a domain event to internal state (called by replay too)
  protected abstract apply(event: DomainEvent): void

  // Pending events not yet committed
  readonly pendingEvents: DomainEvent[]

  // Replay a sequence of historical events to rebuild state
  replayFrom(events: DomainEvent[]): void

  // Mark pending events as committed (called by EventStore after write)
  clearPendingEvents(): void
}

EventStore interface

interface EventStore {
  append(events: DomainEvent[], expectedVersion: number): Promise<void>
  load(aggregateId: string, fromVersion?: number): Promise<DomainEvent[]>
  loadSnapshot(aggregateId: string): Promise<Snapshot | undefined>
  saveSnapshot(snapshot: Snapshot): Promise<void>
  getStream(aggregateId: string): AsyncGenerator<DomainEvent>
}

interface Snapshot {
  aggregateId: string
  aggregateType: string
  version: number
  state: unknown
  takenAt: Date
}

In-memory event store

function createMemoryEventStore(options?: MemoryEventStoreOptions): EventStore

interface MemoryEventStoreOptions {
  maxStreamLength?: number
  clock?: Clock
  idGenerator?: IdGenerator
  bus?: EventBusLike       // publishes committed events to subscribers
  logger?: LoggerLike
}

Aggregate repository

// Combines EventStore with Aggregate reconstruction
function createAggregateRepository<T extends Aggregate<unknown>>(
  options: AggregateRepositoryOptions<T>
): AggregateRepository<T>

interface AggregateRepository<T extends Aggregate<unknown>> {
  load(aggregateId: string): Promise<T>
  save(aggregate: T): Promise<void>
  exists(aggregateId: string): Promise<boolean>
}

interface AggregateRepositoryOptions<T extends Aggregate<unknown>> {
  store: EventStore
  factory: (id: string) => T
  snapshotThreshold?: number     // take snapshot every N events; default: disabled
}

Projection builder

function createProjection<TState>(options: ProjectionOptions<TState>): Projection<TState>

interface ProjectionOptions<TState> {
  initialState: TState
  handlers: ProjectionHandlers<TState>
}

type ProjectionHandlers<TState> = Record<
  string,
  (state: TState, event: DomainEvent) => TState
>

interface Projection<TState> {
  project(events: DomainEvent[]): TState
  projectStream(stream: AsyncIterable<DomainEvent>): Promise<TState>
}

Upcasting (event schema migration)

// Register an upcaster that migrates payloads from old event versions
function registerUpcaster(options: UpcasterOptions): void

interface UpcasterOptions {
  eventType: string
  fromVersion: number
  toVersion: number
  upcast: (payload: unknown) => unknown
}

// Upcast a sequence of events to the latest schema version
function upcastEvents(events: DomainEvent[]): DomainEvent[]

Error handling

class EventSourcingError extends SuaveplanError<EventSourcingErrorCode>

type EventSourcingErrorCode =
  | "ES_CONCURRENCY_CONFLICT"
  | "ES_AGGREGATE_NOT_FOUND"
  | "ES_SNAPSHOT_INVALID"
  | "ES_UPCASTER_MISSING"
  | "ES_STREAM_CORRUPT"
  | "ES_APPEND_FAILED"

function registerEventSourcingErrorCodes(): void

Zod schemas

const DomainEventSchema: ZodSchema
const SnapshotSchema: ZodSchema
const MemoryEventStoreOptionsSchema: ZodSchema
const AggregateRepositoryOptionsSchema: ZodSchema

New additions for genesis

1. Optimistic concurrency

EventStore.append takes expectedVersion. If the current stream version does not match, it throws EventSourcingError("ES_CONCURRENCY_CONFLICT"). The AggregateRepository passes aggregate.version as the expected version, retrying the load-modify-save cycle up to the caller-configured maxRetries.

2. Snapshot-based reconstruction

When snapshotThreshold is set, AggregateRepository.save automatically saves a snapshot when aggregate.version % snapshotThreshold === 0. On load, the repository loads the latest snapshot first, then replays only events after the snapshot version.

3. @suaveplan/diff delta payloads

The in-memory store optionally stores event payloads as JSON Patch deltas against the previous event in the same stream (using @suaveplan/diff). EventStore.load transparently upcasts deltas back to full payloads. This is an opt-in compression mode controlled by MemoryEventStoreOptions.deltaPayloads.

4. @suaveplan/events publish-after-commit

When bus is provided in MemoryEventStoreOptions, the store emits each committed DomainEvent onto the bus as domain-event:<aggregateType>:<eventType> after a successful append. This enables subscriptions without polling the store.

5. OTel instrumentation

  • store.append — span es.append, attributes: es.aggregate_id, es.aggregate_type, es.event_count, es.version
  • store.load — span es.load, attributes: es.aggregate_id, es.event_count
  • repository.save — span es.repository.save, includes optimistic concurrency retry count
  • Counters: es.events_appended_total, es.concurrency_conflicts_total, es.snapshots_taken_total
  • Histogram: es.append_duration_ms, es.load_duration_ms

6. Property-based tests (fast-check)

  • Replay determinism: replaying the same event sequence always produces the same aggregate state
  • Optimistic concurrency: concurrent appends with the same expected version produce exactly one success
  • Projection completeness: projecting all events returns the same state as building incrementally
  • Snapshot round-trip: saving and loading a snapshot yields identical aggregate state

7. Benchmark suite (src/__benchmarks__/)

  • Load aggregate with 100, 1k, 10k events (no snapshot)
  • Load aggregate with 10k events and snapshot at 500
  • Append 1k events to a single stream

File layout

universal/events/event-sourcing/
  package.json
  tsconfig.json
  vitest.config.ts
  src/
    index.ts
    error-codes.ts
    constants/
      index.ts
    types/
      common.ts
      common.md
    core/
      aggregate/
        aggregate.ts
        aggregate.md
        aggregate.types.ts
        aggregate.test.ts
        index.ts
      event-store/
        event-store.ts
        event-store.md
        event-store.types.ts
        event-store.test.ts
        index.ts
      memory-event-store/
        memory-event-store.ts
        memory-event-store.md
        memory-event-store.types.ts
        memory-event-store.test.ts
        index.ts
      aggregate-repository/
        aggregate-repository.ts
        aggregate-repository.md
        aggregate-repository.types.ts
        aggregate-repository.test.ts
        index.ts
    projection/
      projection-builder/
        projection-builder.ts
        projection-builder.md
        projection-builder.types.ts
        projection-builder.test.ts
        index.ts
    upcasting/
      upcaster/
        upcaster.ts
        upcaster.md
        upcaster.types.ts
        upcaster.test.ts
        index.ts
    __benchmarks__/
      event-sourcing.bench.ts
  tests/
    integration.test.ts
    property.test.ts

Impact

Downstream packages: any domain-model package following event-sourcing architecture. Enables CQRS read-model construction via projections and cross-context publishing via @suaveplan/events.

Workspace dep prerequisites: @suaveplan/events, @suaveplan/diff, @suaveplan/error, @suaveplan/types must be published to Verdaccio.

Breaking changes: none (new package).