ADR: Effect Pattern for Core APIs #298
No reviewers
Labels
No labels
adr
automated
bug
chore
dependencies
documentation
enhancement
epic
github-actions
P1-high
P2-medium
P3-low
release
research
rust
No milestone
No project
No assignees
1 participant
Notifications
Due date
No due date set.
Dependencies
No dependencies set.
Reference
jwilger/eventcore!298
Loading…
Add table
Add a link
Reference in a new issue
No description provided.
Delete branch "adr/effect-pattern-for-core-apis"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Status
Proposed
Date
2026-02-17
Context
EventCore's core functions (
executeandrun_projection) currently accept trait implementations as direct parameters and call their methods internally:Inside these functions, infrastructure interactions happen directly:
executecallsstore.read_stream()andstore.append_events()as part of the five-phase pipeline (stream resolution, read/version capture, state reconstruction, business logic, atomic append)run_projectioncallsbackend.read_events(),backend.load(),backend.save(), andbackend.try_acquire()inside its poll loopWhile
CommandLogic::handle()is already pure (state in, events out), the execution pipeline and projection infrastructure that surround it are tightly coupled to trait method calls on injected dependencies.Key Forces:
CommandLogic::handle()already follows a functional core pattern. The surrounding pipeline should match this discipline.Decision
Restructure EventCore's core functions so they emit effect requests instead of directly calling trait methods. A shell/runtime loop routes each effect to the appropriate trait implementation, feeds the result back, and repeats until the function completes.
Effect Types
Event Store Effects (used by
execute):ReadStream { stream_id }— replaces directEventStore::read_streamcallsAppendEvents { writes }— replaces directEventStore::append_eventscallsProjection Effects (used by
run_projection):TryAcquireLock { subscription_name }— replaces directProjectorCoordinator::try_acquirecallsLoadCheckpoint { name }— replaces directCheckpointStore::loadcallsSaveCheckpoint { name, position }— replaces directCheckpointStore::savecallsReadEvents { filter, page }— replaces directEventReader::read_eventscallsSleep { duration }— replaces directtokio::time::sleepcallsShell/Runtime Loop
The public API signatures remain free functions. Internally, each function becomes a state machine that yields effects:
The internal state machines (
ExecutePipeline,ProjectionLoop, etc.) arepub(crate)— not part of the public API (consistent with ADR-030).What Changes
executeinternalsstore.read_stream()/store.append_events()directlyStoreEffect, shell dispatches toEventStorerun_projectioninternalsbackend.read_events()/backend.load()/backend.save()/backend.try_acquire()directlyProjectionEffect, shell dispatches to backend traitsCommandLogic::handle()execute(store, command, policy)EventStore,EventReader, etc.What Does NOT Change
eventcore-typesCommandLogictrait and domain event model (ADR-012)eventcore-memory,eventcore-postgres)Rationale
Why effects over direct trait calls:
The effect pattern separates describing what to do from performing it — the same principle behind
CommandLogic::handle()being pure, extended to the surrounding pipeline.EventStoretraits. Pipeline logic tests become fast, deterministic, and focused.Why keep the public API unchanged:
Users should not need to understand effects to use EventCore. The shell loop is an implementation detail.
execute(store, command, policy)remains the primary API.Why internal state machines:
Making the state machine explicit (rather than relying on
async fnsuspension) gives control over yield points (effects, not arbitrary.awaitpoints), enables testing without an async runtime, and clearly separates the machine (pure) from the shell (effectful).Trade-offs Accepted:
Consequences
Positive
Negative
executeandrun_projectionNeutral
eventcorecrateAlternatives Considered
Keep direct trait calls, add middleware trait
Introduce a
Middleware<S: EventStore>wrapping store calls. Solves composability but not testability or observability. Rejected.Async generator / stream-based approach
Use
async-streamor nightly generators. Premature dependency on unstable features. Can migrate later if generators stabilize. Rejected.Full algebraic effects (a la Eff/Koka)
Massive complexity for a focused library. No established Rust ecosystem support. Over-engineered. Rejected.
Related Decisions
pub(crate), not exported