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>
141 lines
6.2 KiB
Markdown
141 lines
6.2 KiB
Markdown
# Domain Model Summary: Event Sourcing
|
|
|
|
## Core Finding
|
|
|
|
The **Event Sourcing** bounded context in Aether is **correctly modeled as tactical DDD**. The library implements:
|
|
|
|
1. **One core aggregate** (ActorEventStream) protecting one critical invariant: monotonic versioning
|
|
2. **Clear commands** (SaveEvent, GetLatestVersion, GetEvents)
|
|
3. **Immutable events** (published after SaveEvent succeeds)
|
|
4. **Policies** (version validation, append-only persistence, idempotent publishing)
|
|
5. **Read models** (event stream, current version, snapshots, namespace-scoped events)
|
|
6. **Value objects** (Event, ActorSnapshot, Version)
|
|
7. **Namespace isolation** (logical boundaries at storage and bus level)
|
|
|
|
## Strategic Alignment
|
|
|
|
Aether's vision states: **"Primitives over frameworks"**
|
|
|
|
The Event Sourcing context perfectly embodies this:
|
|
|
|
- **Provides primitives**: Event, EventStore, Version, Snapshot
|
|
- **Not a framework**: No command handlers, no projections, no sagas
|
|
- **Caller controls logic**: Version passed by caller, retry on conflict is caller's decision
|
|
- **Composable**: InMemoryEventStore (testing), JetStreamEventStore (production), both implement same interface
|
|
- **Optional features**: Snapshots are separate interface, tracing metadata is optional
|
|
|
|
## The One Invariant
|
|
|
|
**Monotonic Versioning**: Each actor's event stream must have strictly increasing version numbers.
|
|
|
|
```
|
|
Version: 1 → 2 → 3 → 4 → ... (strictly increasing)
|
|
↓ ↓ ↓ ↓
|
|
EventA EventB EventC EventD
|
|
```
|
|
|
|
Why this invariant exists:
|
|
- **Optimistic concurrency control**: Detect when another writer moved the actor forward
|
|
- **Event ordering**: Guarantee causal ordering within single actor stream
|
|
- **Idempotence detection**: Caller can tell if their write succeeded by checking version
|
|
|
|
## The Two Critical Design Decisions
|
|
|
|
### Decision 1: Version Passed by Caller
|
|
|
|
**NOT auto-incremented by library.** Caller does:
|
|
```go
|
|
currentVersion, _ := store.GetLatestVersion(actorID)
|
|
event.Version = currentVersion + 1
|
|
err := store.SaveEvent(event)
|
|
```
|
|
|
|
**Why?** Because caller knows:
|
|
- Whether event is idempotent (same command, safe to skip if already saved)
|
|
- What the expected previous version should be
|
|
- How to detect if another writer won the race
|
|
|
|
Auto-increment would hide this logic and break idempotence safety.
|
|
|
|
### Decision 2: Fail on Conflict, Don't Retry
|
|
|
|
**SaveEvent returns error if version conflict.** Caller decides next action:
|
|
```go
|
|
err := store.SaveEvent(event)
|
|
if errors.Is(err, aether.ErrVersionConflict) {
|
|
// Caller decides:
|
|
// - Retry with new version? (for legitimate concurrent write)
|
|
// - Skip? (for duplicate retry of same command)
|
|
// - Alert? (for unexpected behavior)
|
|
// - Fail-fast? (for critical paths)
|
|
}
|
|
```
|
|
|
|
**Why?** Because library can't decide. Auto-retry + auto-increment could turn conflict into invisible duplicate write.
|
|
|
|
## What's NOT Modeled Here
|
|
|
|
The Event Sourcing context does NOT model:
|
|
- **Business logic** (e.g., "order can't exceed inventory"): That's Inventory or Sales context
|
|
- **Saga coordination** (e.g., "when Order placed, reserve inventory"): That's Policy context
|
|
- **Event schema** (e.g., "OrderPlaced must have productId"): That's domain layer above this
|
|
- **Read model projections** (e.g., "all orders for customer X"): That's Query/Reporting context
|
|
- **Multi-aggregate transactions** (e.g., "update order and inventory atomically"): That's domain layer
|
|
|
|
The library provides the **primitives**. Downstream contexts provide the **business logic**.
|
|
|
|
## Code Alignment: "Brownfield" Assessment
|
|
|
|
Current implementation aligns perfectly with intended model. No refactoring needed:
|
|
|
|
✓ **Invariant enforced**: SaveEvent validates version > current (both in-memory and JetStream)
|
|
✓ **Append-only**: No delete/update in interface
|
|
✓ **Commands explicit**: SaveEvent, GetLatestVersion, GetEvents are clear intents
|
|
✓ **Events immutable**: Event struct has no setters, metadata helpers don't modify fields
|
|
✓ **Policies enforced**: Version validation, idempotent publishing (via message ID)
|
|
✓ **Read models clear**: GetEvents, GetLatestVersion, GetLatestSnapshot, Subscribe
|
|
✓ **Value objects**: Event, ActorSnapshot, Version are attribute-defined
|
|
✓ **Namespace isolation**: Stream-level prefix (JetStream), pattern matching (EventBus)
|
|
|
|
## The Elegant Part: Versioning
|
|
|
|
The monotonic version approach solves three problems with one invariant:
|
|
|
|
1. **Detect contention**: If SaveEvent fails with VersionConflict, another writer won
|
|
2. **Prevent duplicates**: Caller uses event ID + version as idempotence key
|
|
3. **Enable causal ordering**: Version numbers guarantee order within single actor stream
|
|
|
|
This is **not a workaround**—it's the fundamental pattern of optimistic concurrency control. And it's **caller-controlled**, not library magic. Perfect example of "primitives over frameworks."
|
|
|
|
## Gaps (Minor, Optional)
|
|
|
|
Four improvements identified (all marked as future/optional):
|
|
|
|
1. **Snapshot invalidation policy**: Snapshots not auto-invalidated when too many events added
|
|
2. **Bulk operations**: No SaveMultipleEvents for atomic saves
|
|
3. **Event schema evolution**: Caller responsible for versioning (Data is map[string]interface{})
|
|
4. **Deduplication on save**: Could reject duplicate event IDs, currently library doesn't
|
|
|
|
None are critical. All are optimizations or edge cases. Core model is sound.
|
|
|
|
## Why This Matters
|
|
|
|
This domain model demonstrates how to apply DDD to a library (not an app):
|
|
|
|
- **Aggregates are tiny** (one entity, one invariant) because library scope is small
|
|
- **Commands are few** (SaveEvent, GetEvents, GetLatestVersion) because caller brings domain
|
|
- **Events are facts** (EventStored) not commands (not "SaveEvent")
|
|
- **Policies are infrastructure** (version validation, append-only) not business rules
|
|
- **Read models are queries** (with no invariants) for deriving state
|
|
|
|
This is DDD applied correctly to **infrastructure code**, where the business domain lives upstream.
|
|
|
|
## Use This Model For:
|
|
|
|
1. **Onboarding** new developers: "Here's how Event Sourcing works in Aether"
|
|
2. **Design review**: "Are we breaking monotonic versioning invariant?"
|
|
3. **Extension design**: "Should this be a policy, policy, or new command?"
|
|
4. **Backward compatibility**: "If we change SaveEvent signature, what breaks?"
|
|
5. **Testing strategy**: "What are the critical invariants to test?"
|
|
|