Files
aether/.product-strategy/DOMAIN_MODEL_SUMMARY.md
Hugo Nijhuis 271f5db444
Some checks failed
CI / build (push) Successful in 21s
CI / integration (push) Failing after 2m1s
Move product strategy documentation to .product-strategy directory
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>
2026-01-12 23:57:20 +01:00

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?"