feat: implement Effect Pattern for core APIs (ADR-033) #348
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!348
Loading…
Add table
Add a link
Reference in a new issue
No description provided.
Delete branch "feat/299-effect-pattern"
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?
Summary
Implements the Effect Pattern described in ADR-033 (issue #299), refactoring
execute()andrun_projection()into pure state machines that yield effects, with thin shell loops dispatching effects to backend traits.Caller-Driven Effects (User-Facing)
EffectandEffectResultassociated types toCommandLogicHandleDecisionenum (Done/Effect) replacingResult<NewEvents, CommandError>ashandle()return typeresume()method for continuing after effect fulfillmentexecute()now returnsExecution<C, S>withSuccess/Error/EffectvariantsEffect = ()— fully backward compatibleInternal State Machine Refactor
ExecutePipeline: Pure state machine yieldingStoreEffect(ReadStream, AppendEvents, Sleep) withresume()accepting resultsProjectionPipeline: Pure state machine yieldingProjectionEffect(ReadEvents, LoadCheckpoint, SaveCheckpoint, Sleep)execute()andProjectionRunner::run()are now thin shell loops dispatching effects to backendsBreaking Changes
CommandLogic::handle()returnsHandleDecision<Self>instead ofResult<NewEvents, CommandError>CommandLogicrequirestype Effectandtype EffectResultassociated typesCommandLogic::StaterequiresCloneboundexecute()returnsExecution<C, S>instead ofResult<ExecutionResponse, CommandError>require!macro usage inhandle()requires IIFE wrapper patternCloses #299
Test plan
🤖 Generated with Claude Code
Having a lot of second thoughts about this approach.
Walk me through the pros and cons.
Is this allowing us to do anything we either could not do or could not do as well versus either requiring all commands to execute only on their input values and current, event-derived state or passing in "services" via trait implementations as command properties for any command that needs to include execution of IO code while making decisions?
@ -117,17 +121,19 @@ impl CommandLogic for Withdraw {}I'm not sure I love the ergonomics or readability of this. It seems awkwardly nested when there are really three possibilities: done and here are events, done and here is an error, continue after giving me the results of these requested effects. This structure doesn't feel like it communicates that well.
@ -120,3 +122,4 @@HandleDecision::Done(Ok(vec![BankAccountEvent::MoneyDeposited {account_id: self.account_id.clone(),amount: self.amount,}]Also not sure how I feel about having to set these up for every command when it is likely that most commands will not actually need this functionality. Starting to wonder if this is a good idea at all as opposed to just passing "services" into commands as input where the services just require a trait implementation. (That is, maybe we don't do this PR?)
@ -117,17 +121,19 @@ impl CommandLogic for Withdraw {}Agreed. The nesting here is real —
HandleDecision::Done(Ok(vec![...].into()))buries the common case (emit events) under two layers of wrapping. Helper methods likeHandleDecision::success(events)andHandleDecision::error(err)could flatten this, but it's papering over a design that made the simple path harder to express. The three-way decision (events / error / effect) is genuinely the semantic model, but forcing it through nested enums doesn't communicate it well — you're right that it reads as awkward nesting rather than clean branching.@ -120,3 +122,4 @@HandleDecision::Done(Ok(vec![BankAccountEvent::MoneyDeposited {account_id: self.account_id.clone(),amount: self.amount,}]This is a fair concern. Rust doesn't support associated type defaults on stable, so there's no way to make
type Effect = (); type EffectResult = ();optional — every command pays this tax. Theresume()method with a panicking default is also a code smell: it means the trait has a method that most implementations must never call, enforced by a runtime panic rather than the type system.This boilerplate cost goes directly to the question you raised in the top-level review — see the full analysis there.
Pros/Cons Analysis: Effect Pattern vs. Alternatives
You asked whether this approach enables anything that couldn't be achieved with simpler alternatives. Here's an honest comparison.
What Changed (for reference)
handle()returnResult<NewEvents, CommandError>HandleDecision<Self>(Done/Effect)execute()returnResult<ExecutionResponse, CommandError>Execution<C, S>(Success/Error/Effect)Event,StateEvent,State,Effect,EffectResultDefault + SendDefault + Clone + Sendresume()with panicking defaultApproach 1: Effect Pattern (This PR)
Pros:
handle()stays sync and pure — cannot do I/O, enforced by the type systemCons:
type Effect = (); type EffectResult = ();HandleDecision::Done(Ok(vec![...].into()))resume()default panics at runtime — the trait has a method most impls must never callExecutionenum forces three-way matching even when effects are impossibleState: Clonebound added for all commandsApproach 2: Async
handle()with Service InjectionCommands that need I/O take service traits as struct fields.
handle()becomes async so it can call injected services.Pros:
execute()keeps its cleanResult<ExecutionResponse, CommandError>returnCons:
handle()is no longer guaranteed pure — convention only, not type-enforcedhandle()async is itself a breaking change (though arguably smaller than this PR's changes)dyndispatch (though CommandLogic uses generics, not dyn)Approach 3: Pure Commands with External Orchestration
Commands stay completely pure. All I/O happens before
execute(), with results passed as command input.Pros:
handle()stays sync, pure, trivially testableCons:
Does the Effect Pattern Enable Anything the Others Can't?
No. All three approaches achieve the same functional outcomes. The differences are:
The Internal Refactor
Worth noting: the
ExecutePipelineandProjectionPipelinestate machine extractions are independently valuable regardless of which caller-facing approach we choose. They make the core logic unit-testable without real stores. If we abandon the caller-driven effects, we could keep the internal refactor in a separate PR.My Assessment
The effect pattern's unique value is enforced purity of
handle()— it's sync, so it literally cannot do I/O. This is a real property, but the cost is high: every command pays boilerplate tax for a feature most won't use, and the common path (emit events) becomes harder to express.Service injection via async
handle()would preserve the simple API for effect-free commands while giving effect-ful commands a familiar, well-understood mechanism. The trade-off is losing enforced purity, but in a library where the convention is well-documented and the audience is Rust developers who chose an event-sourcing framework, convention is probably sufficient.If the conclusion is to not proceed with this PR, I'd suggest:
handle()+ service injection if the I/O-during-commands use case is still a priority@ -120,3 +122,4 @@HandleDecision::Done(Ok(vec![BankAccountEvent::MoneyDeposited {account_id: self.account_id.clone(),amount: self.amount,}]I wonder if we could have a separate trait that gets implemented for commands that need to use effects. That might give us a means to represent this type of command without requiring it on every command.
Closing this PR. The caller-driven effects pattern adds significant API complexity (boilerplate for every command, nested return types, panicking
resume()default) for a benefit (enforced purity) that doesn't justify the cost.Decision: Commands receive all non-system-state inputs pre-calculated. Anything requiring time-of-execution consistency must be represented in internal state (events). TOCTOU concerns with external systems are inherent regardless of approach — no framework-level mechanism can solve them.
The internal state machine refactor (ExecutePipeline, ProjectionPipeline) is independently valuable and will be submitted as a separate PR.
Pull request closed