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>
6.2 KiB
Domain Model Summary: Event Sourcing
Core Finding
The Event Sourcing bounded context in Aether is correctly modeled as tactical DDD. The library implements:
- One core aggregate (ActorEventStream) protecting one critical invariant: monotonic versioning
- Clear commands (SaveEvent, GetLatestVersion, GetEvents)
- Immutable events (published after SaveEvent succeeds)
- Policies (version validation, append-only persistence, idempotent publishing)
- Read models (event stream, current version, snapshots, namespace-scoped events)
- Value objects (Event, ActorSnapshot, Version)
- 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:
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:
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:
- Detect contention: If SaveEvent fails with VersionConflict, another writer won
- Prevent duplicates: Caller uses event ID + version as idempotence key
- 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):
- Snapshot invalidation policy: Snapshots not auto-invalidated when too many events added
- Bulk operations: No SaveMultipleEvents for atomic saves
- Event schema evolution: Caller responsible for versioning (Data is map[string]interface{})
- 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:
- Onboarding new developers: "Here's how Event Sourcing works in Aether"
- Design review: "Are we breaking monotonic versioning invariant?"
- Extension design: "Should this be a policy, policy, or new command?"
- Backward compatibility: "If we change SaveEvent signature, what breaks?"
- Testing strategy: "What are the critical invariants to test?"