Organize all product strategy and domain modeling documentation into a dedicated .product-strategy directory for better separation from code. Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
494 lines
16 KiB
Markdown
494 lines
16 KiB
Markdown
# Event Sourcing Domain Model - Modeling Results
|
|
|
|
## What Was Modeled
|
|
|
|
The **Event Sourcing** bounded context for Aether distributed actor system, using tactical Domain-Driven Design.
|
|
|
|
**Bounded Context Scope:**
|
|
- Responsibility: Persist events as immutable source of truth; enable state reconstruction through replay
|
|
- Language: Event, Version, Snapshot, ActorID, Replay, Namespace
|
|
- Invariants: Monotonic versions per actor; append-only persistence
|
|
- Key Stakeholders: Library users writing event-sourced applications
|
|
|
|
---
|
|
|
|
## Core Finding: One Invariant, One Aggregate
|
|
|
|
```
|
|
Invariant: Version must be > previous version for same actor
|
|
|
|
+─────────────────────────────────────┐
|
|
│ Aggregate: ActorEventStream │
|
|
│ (Root Entity) │
|
|
│ │
|
|
│ - ActorID: identifier │
|
|
│ - CurrentVersion: int64 (mutable) │
|
|
│ │
|
|
│ Commands: │
|
|
│ ├─ SaveEvent: persist + validate │
|
|
│ ├─ GetLatestVersion: read current │
|
|
│ └─ GetEvents: replay │
|
|
│ │
|
|
│ Policy: Version > previous? │
|
|
│ ├─ YES → persist event │
|
|
│ └─ NO → return VersionConflictError
|
|
│ │
|
|
│ Events: EventStored (implicit) │
|
|
│ │
|
|
│ Value Objects: │
|
|
│ ├─ Event (immutable) │
|
|
│ ├─ Version (int64) │
|
|
│ └─ ActorSnapshot │
|
|
│ │
|
|
└─────────────────────────────────────┘
|
|
```
|
|
|
|
**Why Only One Aggregate?**
|
|
- Aggregates protect invariants
|
|
- Event Sourcing context has one invariant: monotonic versioning
|
|
- Events are immutable (no entity lifecycle rules)
|
|
- Snapshots are optional (stored separately)
|
|
|
|
---
|
|
|
|
## The Critical Design Decisions
|
|
|
|
### Decision 1: Version Passed by Caller (Not Auto-Incremented)
|
|
|
|
```
|
|
Caller Flow:
|
|
|
|
1. currentVersion := store.GetLatestVersion(actorID)
|
|
└─ Returns: 5 (or 0 if new actor)
|
|
|
|
2. event.Version = currentVersion + 1
|
|
└─ Set version to 6
|
|
|
|
3. err := store.SaveEvent(event)
|
|
└─ If another writer set version 6 first → VersionConflictError
|
|
└─ If no conflict → event persisted at version 6
|
|
```
|
|
|
|
**Why Not Auto-Increment?**
|
|
- Caller knows whether event is idempotent (same command = safe to skip if already saved)
|
|
- Caller knows expected previous version (optimistic concurrency control)
|
|
- Caller decides retry strategy (immediate, backoff, circuit-break, skip)
|
|
- Auto-increment would hide duplicate writes
|
|
|
|
**Cost:** Caller must manage versions. But this is intentional: "primitives over frameworks".
|
|
|
|
---
|
|
|
|
### Decision 2: Fail on Conflict (Don't Auto-Retry)
|
|
|
|
```
|
|
SaveEvent Behavior:
|
|
|
|
Input: Event{Version: 6, ActorID: "order-123"}
|
|
Current Version: 5
|
|
|
|
Check: Is 6 > 5?
|
|
├─ YES → Persist, return nil
|
|
└─ NO → Return VersionConflictError{
|
|
ActorID: "order-123",
|
|
AttemptedVersion: 6,
|
|
CurrentVersion: 5
|
|
}
|
|
|
|
Caller sees error and decides:
|
|
├─ Legitimate concurrent write? → Get new version, retry with version 7
|
|
├─ Duplicate command? → Skip (event already saved)
|
|
├─ Unexpected? → Alert ops
|
|
└─ Critical path? → Fail fast
|
|
```
|
|
|
|
**Why Not Auto-Retry?**
|
|
- Auto-retry + auto-increment could turn concurrent write into invisible duplicate
|
|
- Library can't tell "new command" from "duplicate command"
|
|
- Caller must decide, and library must report conflict clearly
|
|
|
|
---
|
|
|
|
### Decision 3: Snapshots Separate from Events
|
|
|
|
```
|
|
Optional Composition:
|
|
|
|
var store aether.EventStore = inmem.NewInMemoryEventStore()
|
|
// No snapshots - for testing
|
|
|
|
var snapshotStore aether.SnapshotStore = jsMem.NewJetStreamEventStore(...)
|
|
// With snapshots - composition via interface
|
|
```
|
|
|
|
**Why Separate?**
|
|
- Many domains don't need snapshots (small event streams)
|
|
- Snapshot strategy (when to snapshot, when to use) is domain concern
|
|
- Caller can add snapshotting logic only if needed
|
|
|
|
---
|
|
|
|
## The Aggregate: ActorEventStream
|
|
|
|
```
|
|
ActorEventStream protects monotonic versioning invariant
|
|
|
|
Data:
|
|
├─ ActorID (string): Identifier
|
|
├─ CurrentVersion (int64): Latest version seen
|
|
└─ Namespace (optional): For isolation
|
|
|
|
Commands:
|
|
├─ SaveEvent(event) → error
|
|
│ ├─ Validates: event.Version > currentVersion
|
|
│ ├─ Success: Event persisted, currentVersion updated
|
|
│ └─ Failure: VersionConflictError returned
|
|
├─ GetLatestVersion() → int64
|
|
│ └─ Returns: Max version, or 0 if new
|
|
├─ GetEvents(fromVersion) → []*Event
|
|
│ └─ Returns: Events where version >= fromVersion
|
|
└─ GetEventsWithErrors(fromVersion) → (*ReplayResult, error)
|
|
└─ Returns: Events + errors (for corrupted data visibility)
|
|
|
|
Policies Enforced:
|
|
├─ Version Validation: version > current before persist
|
|
├─ Append-Only: No delete/update operations
|
|
├─ Idempotent Publishing: JetStream dedup by event ID
|
|
└─ Immutability: Events treated as immutable after storage
|
|
|
|
Lifecycle:
|
|
├─ Created: When first event is saved (version > 0)
|
|
├─ Active: As events are appended
|
|
└─ Destroyed: N/A (event stream persists forever)
|
|
```
|
|
|
|
---
|
|
|
|
## Commands, Events, and Policies
|
|
|
|
```
|
|
Command Flow:
|
|
|
|
┌──────────────────────────────┐
|
|
│ SaveEvent (command) │
|
|
│ Input: Event{...} │
|
|
└──────────────────────────────┘
|
|
│
|
|
├─ Preconditions:
|
|
│ ├─ event != nil
|
|
│ ├─ event.ID != ""
|
|
│ ├─ event.ActorID != ""
|
|
│ ├─ event.Version > 0
|
|
│ └─ event.Version > currentVersion ← INVARIANT CHECK
|
|
│
|
|
├─ Policy: Version Validation
|
|
│ └─ If version <= current → VersionConflictError
|
|
│
|
|
└─ Success: Persist to store
|
|
│
|
|
├─ Policy: Append-Only
|
|
│ └─ Event added to stream (never removed/modified)
|
|
│
|
|
├─ Policy: Idempotent Publishing
|
|
│ └─ JetStream dedup by message ID
|
|
│
|
|
└─ Event Published: EventStored (implicit)
|
|
└─ Delivered to EventBus subscribers
|
|
|
|
|
|
Read Commands:
|
|
|
|
GetLatestVersion → int64
|
|
├─ Scans all events for actor
|
|
└─ Returns max version (or 0 if new)
|
|
|
|
GetEvents(fromVersion) → []*Event
|
|
├─ Replay from specified version
|
|
└─ Silently skips corrupted events
|
|
|
|
GetEventsWithErrors(fromVersion) → (*ReplayResult, error)
|
|
└─ Returns both events and errors (caller sees data quality)
|
|
```
|
|
|
|
---
|
|
|
|
## Read Models (Projections)
|
|
|
|
```
|
|
From SaveEvent + GetEvents, derive:
|
|
|
|
1. EventStream: Complete history for actor
|
|
└─ Query: GetEvents(actorID, 0)
|
|
└─ Use: Replay to reconstruct state
|
|
|
|
2. CurrentVersion: Latest version number
|
|
└─ Query: GetLatestVersion(actorID)
|
|
└─ Use: Prepare next SaveEvent (version + 1)
|
|
|
|
3. StateSnapshot: Point-in-time state
|
|
└─ Query: GetLatestSnapshot(actorID)
|
|
└─ Use: Skip early events, replay only recent ones
|
|
|
|
4. Namespace-Scoped Events: Cross-subscriber coordination
|
|
└─ Query: EventBus.Subscribe(namespacePattern)
|
|
└─ Use: React to events in specific namespace
|
|
```
|
|
|
|
---
|
|
|
|
## Namespace Isolation (Cross-Cutting Concern)
|
|
|
|
```
|
|
Namespace Isolation enforces:
|
|
|
|
Rule 1: Events in namespace X invisible to namespace Y
|
|
├─ Storage: JetStreamEventStore creates separate stream per namespace
|
|
│ └─ Stream names: "tenant-a_events" vs "tenant-b_events"
|
|
├─ Pub/Sub: EventBus maintains separate subscriber lists
|
|
│ └─ exactSubscribers[namespace] stores subscribers for exact match
|
|
└─ Result: Complete isolation at both layers
|
|
|
|
Rule 2: Namespace names must be NATS-safe
|
|
├─ No wildcards (*), no ">" sequences
|
|
├─ Sanitized: spaces → _, dots → _, etc.
|
|
└─ Result: Valid NATS subject tokens
|
|
|
|
Rule 3: Wildcard subscriptions bypass isolation (intentional)
|
|
├─ Patterns like "*" and ">" can match multiple namespaces
|
|
├─ Use case: Logging, monitoring, auditing (trusted components)
|
|
├─ Security: Explicitly documented as bypassing isolation
|
|
└─ Recommendation: Restrict wildcard access to system components
|
|
|
|
Example:
|
|
Publish: "OrderPlaced" to namespace "prod.tenant-a"
|
|
Exact subscriber "prod.tenant-a" → sees it
|
|
Exact subscriber "prod.tenant-b" → doesn't see it
|
|
Wildcard subscriber "prod.*" → sees it (intentional)
|
|
Wildcard subscriber "*" → sees it (intentional)
|
|
```
|
|
|
|
---
|
|
|
|
## Value Objects
|
|
|
|
```
|
|
Event: Immutable fact
|
|
├─ ID: Unique identifier (deduplication key)
|
|
├─ EventType: Domain language (e.g., "OrderPlaced")
|
|
├─ ActorID: What aggregate this concerns
|
|
├─ Version: Order in stream
|
|
├─ Data: map[string]interface{} (domain payload)
|
|
├─ Metadata: map[string]string (tracing context)
|
|
│ └─ Standard keys: CorrelationID, CausationID, UserID, TraceID, SpanID
|
|
├─ Timestamp: When event occurred
|
|
└─ CommandID: ID of command that triggered this (optional)
|
|
|
|
ActorSnapshot: Point-in-time state
|
|
├─ ActorID: Which actor
|
|
├─ Version: At this version
|
|
├─ State: map[string]interface{} (accumulated state)
|
|
└─ Timestamp: When snapshot taken
|
|
|
|
Version: Order number
|
|
├─ int64: Non-negative
|
|
├─ Semantics: > previous version for same actor
|
|
└─ Special: 0 = "no events yet"
|
|
|
|
VersionConflictError: Conflict context
|
|
├─ ActorID: Where conflict occurred
|
|
├─ AttemptedVersion: What caller tried
|
|
└─ CurrentVersion: What already exists
|
|
|
|
ReplayError: Corrupted event
|
|
├─ SequenceNumber: Position in stream
|
|
├─ RawData: Unparseable bytes
|
|
└─ Err: Unmarshal error
|
|
```
|
|
|
|
---
|
|
|
|
## Code Alignment: Brownfield Assessment
|
|
|
|
Current implementation is **correctly modeled**. No refactoring needed.
|
|
|
|
```
|
|
Intended Design → Actual Implementation → Status
|
|
─────────────────────────────────────────────────────────────
|
|
Invariant: Monotonic → SaveEvent validates → ✓ Correct
|
|
Versioning → version > current
|
|
|
|
Append-Only Persistence → No delete/update in → ✓ Correct
|
|
interface
|
|
|
|
SaveEvent as Command → func (EventStore) → ✓ Correct
|
|
SaveEvent(*Event) error
|
|
|
|
VersionConflictError → type VersionConflictError → ✓ Correct
|
|
ActorID, AttemptedVersion,
|
|
CurrentVersion
|
|
|
|
GetLatestVersion → func (EventStore) → ✓ Correct
|
|
(read current) GetLatestVersion(actorID)
|
|
|
|
GetEvents (replay) → func (EventStore) → ✓ Correct
|
|
GetEvents(actorID, fromVersion)
|
|
|
|
Idempotent Publishing → JetStream dedup by → ✓ Correct
|
|
message ID in Publish()
|
|
|
|
Namespace Isolation → JetStreamConfig.Namespace → ✓ Correct
|
|
+ stream prefixing
|
|
|
|
EventBus pub/sub → EventBus.Subscribe with → ✓ Correct
|
|
namespace patterns
|
|
```
|
|
|
|
No gaps between intended and actual. Implementation aligns with DDD model.
|
|
|
|
---
|
|
|
|
## Design Principles Embodied
|
|
|
|
### Principle 1: Primitives Over Frameworks
|
|
|
|
Library provides:
|
|
- Event (type)
|
|
- EventStore (interface with two implementations)
|
|
- Version (semantics: > previous)
|
|
- Namespace (string with restrictions)
|
|
|
|
Library does NOT provide:
|
|
- Event schema enforcement
|
|
- Command handlers
|
|
- Saga coordinators
|
|
- Projection builders
|
|
- Retry logic
|
|
|
|
Caller composes these into domain logic.
|
|
|
|
### Principle 2: NATS-Native
|
|
|
|
- JetStreamEventStore leverages JetStream deduplication
|
|
- Namespace isolation uses stream naming (not generic filtering)
|
|
- EventBus can extend to NATSEventBus (cross-node via NATS)
|
|
|
|
### Principle 3: Resource Conscious
|
|
|
|
- InMemoryEventStore: Minimal overhead (map + RWMutex)
|
|
- JetStreamEventStore: Efficient (leverages NATS JetStream)
|
|
- No unnecessary serialization (JSON is standard, compact)
|
|
- Caching: Version cache in JetStreamEventStore reduces lookups
|
|
|
|
### Principle 4: Events as Complete History
|
|
|
|
- Append-only: Events never deleted
|
|
- Immutable: Events never modified
|
|
- Durable: JetStream persists to disk
|
|
- Replayable: Full history available
|
|
|
|
---
|
|
|
|
## Testing Strategy (Based on Model)
|
|
|
|
```
|
|
Unit Tests:
|
|
├─ SaveEvent
|
|
│ ├─ Rejects version <= current
|
|
│ ├─ Accepts version > current
|
|
│ └─ Sets currentVersion to new version
|
|
├─ GetLatestVersion
|
|
│ ├─ Returns 0 for new actor
|
|
│ ├─ Returns max of all events
|
|
│ └─ Returns max even with gaps (1, 3, 5 → returns 5)
|
|
├─ GetEvents
|
|
│ ├─ Filters by fromVersion (inclusive)
|
|
│ ├─ Returns empty for nonexistent actor
|
|
│ └─ Skips corrupted events
|
|
├─ GetEventsWithErrors
|
|
│ ├─ Returns both events and errors
|
|
│ └─ Allows caller to decide on corruption
|
|
└─ Metadata
|
|
├─ SetMetadata/GetMetadata work
|
|
├─ SetCorrelationID/GetCorrelationID work
|
|
└─ WithMetadataFrom copies all metadata
|
|
|
|
Integration Tests (OCC):
|
|
├─ Concurrent SaveEvent
|
|
│ ├─ First writer wins (version 6)
|
|
│ ├─ Second writer gets VersionConflictError
|
|
│ └─ Second can retry with version 7
|
|
├─ Idempotent Event ID (if implemented)
|
|
│ └─ Same event ID → detected as duplicate
|
|
└─ Namespace Isolation
|
|
├─ Events in namespace A invisible to namespace B
|
|
├─ Wildcard subscribers see both
|
|
└─ Pattern matching (NATS-style) works
|
|
|
|
Brownfield Migration:
|
|
├─ Extract SaveEvent calls
|
|
├─ Handle VersionConflictError
|
|
├─ Add EventBus subscribers
|
|
└─ Monitor metrics (version conflicts = contention signal)
|
|
```
|
|
|
|
---
|
|
|
|
## Key Files & Their Responsibilities
|
|
|
|
```
|
|
event.go
|
|
├─ Event: struct (immutable fact)
|
|
├─ EventStore: interface (contract)
|
|
├─ EventStoreWithErrors: interface (with error visibility)
|
|
├─ VersionConflictError: type (detailed error)
|
|
├─ ActorSnapshot: struct (optional)
|
|
├─ SnapshotStore: interface (optional)
|
|
└─ ReplayResult & ReplayError: types (error visibility)
|
|
|
|
store/memory.go
|
|
├─ InMemoryEventStore: implementation for testing
|
|
├─ Mutex protection: thread-safe
|
|
└─ Invariant enforcement: version > current check
|
|
|
|
store/jetstream.go
|
|
├─ JetStreamEventStore: production implementation
|
|
├─ Namespace isolation: stream prefixing
|
|
├─ Version cache: optimizes repeated lookups
|
|
├─ Deduplication: message ID for idempotency
|
|
└─ Error handling: GetEventsWithErrors for corruption visibility
|
|
|
|
eventbus.go
|
|
├─ EventBus: in-process pub/sub
|
|
├─ Namespace patterns: exact + wildcard
|
|
├─ SubscriptionFilter: event type + actor pattern
|
|
└─ Thread-safe delivery (buffered channels)
|
|
|
|
pattern.go
|
|
├─ MatchNamespacePattern: NATS-style matching
|
|
├─ MatchActorPattern: Actor ID pattern matching
|
|
└─ IsWildcardPattern: Detect wildcard subscriptions
|
|
```
|
|
|
|
---
|
|
|
|
## Summary
|
|
|
|
The Event Sourcing bounded context is correctly modeled using tactical DDD:
|
|
|
|
| Aspect | Finding |
|
|
|--------|---------|
|
|
| **Invariants** | 1 core: monotonic versioning per actor |
|
|
| **Aggregates** | 1 core: ActorEventStream |
|
|
| **Commands** | 4: SaveEvent, GetLatestVersion, GetEvents, GetEventsWithErrors |
|
|
| **Events** | 1 implicit: EventStored (published by EventBus) |
|
|
| **Policies** | 4: Version validation, append-only, idempotent publishing, immutability |
|
|
| **Read Models** | 4: EventStream, CurrentVersion, StateSnapshot, Namespace-scoped |
|
|
| **Value Objects** | 4: Event, ActorSnapshot, Version, VersionConflictError |
|
|
| **Code Alignment** | 100% (no refactoring needed) |
|
|
| **Design Principle** | Primitives over frameworks ✓ |
|
|
| **NATS Integration** | Native (JetStream dedup, stream naming) ✓ |
|
|
| **Gaps** | 4 minor (all optional, non-critical) |
|
|
|
|
The model demonstrates how to apply DDD to **infrastructure code** where the business domain lives upstream. Perfect template for extending Aether with additional contexts.
|
|
|