I've written some F# code to handle commands in the context of event sourcing which works along the lines below:
- Read Events from a specific event store stream
- Build the current state in the given business / domain context accordingly to the events that has just been read right above
- Decide new events based on the current state + read events + a given command
- Write the event that have been newly "decided" to a given stream
- If there is a version mismatch (leading to a failure when it comes to writing the new events to the given stream), there a resilience policy to retry the whole process a certain number of times with a given number of intervals in between (akin to what you can find in Polly).
There is basically two different implementations, a "standard" one and another one validating if some particular events are considered "not okay" slash errors, which makes the handle function returning a Result<'Ok, 'Error>
.
Note: The terminology is a bit off but I've found the terms in quite a few places in the "Event Sourcing world".
The code below works, but it's super-duper redundant and it does feel overly bloated, bear in mind that what is below has already been refactored a couple of times, but I'm not too sure about how I can take this code to a whole new level of clarity.
Not only the code is a bit hard to wrap my own head around, it actually also pains me to see that some paths leading to nowhere cannot be prevented upfront in a declarative way, things like:
| None, Ok _
| Some _, Error _ -> NotImplementedException() |> raise
So what I'm asking is some help to refactor the code below into something easier to reason about, and if possible how can I avoid to process cases that aren't supposed to happen by design.
CommandHandlerUtils.fs
:
[<RequireQualifiedAccess>]
module My.EventSourcing.CommandHandlerUtils
open System
open FSharpPlus.Data
open My.FSharp.Resilience
open My.Infra.EventStore.Common
let internal getInvalidValueException _ _ = NotImplementedException()
let defaultWrongVersionRetryIntervals = Intervals.Seconds [1; 2; 4]
let createStreamKey aggregateName commandMessage =
{ FriendlyName = aggregateName; FriendlyId = commandMessage.AggregateId.ToString() }
/// Build the "history" states based on data available in the "history" events with the reBuild function
/// Note: "history": means "current" here.
let buildHistoryState reBuild (historyEvents: EventRead<'Data, 'Metadata> list) =
historyEvents
|> List.map (fun eventRead -> eventRead.Data)
|> reBuild
/// Read the available events (ascending versions) from a event store and build the current state on top of them.
/// Both values (ie. events and state) are returned.
let buildHistory reBuild (readEvents: ReadEvents<'Data, 'Metadata>) streamKey =
async {
let! historyEvents = readEvents streamKey (All Forward)
let historyState = buildHistoryState reBuild historyEvents
return (historyEvents, historyState)
}
/// Save the given events into an event store stream and return a result containing either:
/// - the events successfully written to the event store stream
/// - the error in case the operation has failed
let saveEvents (tryWriteEvents: TryWriteEvents<_, _, _>) historyEvents events streamKey =
async {
let eventsToWrite =
events
|> List.map (fun event ->
{ Causation = Root
Data = event
Metadata = () })
|> NonEmptyList.ofList
let writeVersion =
if List.isEmpty historyEvents
then Expected 1
else Expected ((List.last historyEvents).StreamVersion + 1)
return! tryWriteEvents streamKey writeVersion eventsToWrite
}
GenericCommandHandler.fs
:
[<RequireQualifiedAccess>]
module My.EventSourcing.GenericCommandHandler
open FSharpPlus.Data
open My.FSharp.Resilience
open My.Infra.EventStore.Common
// 1. Build history state from readEvents
// 2. Decide new events based on command message + history state
// 3. If any events:
// - a. Try Save / Write new events to given stream (ie. command message) into the Event Store
// - b. Return events + state if writing events did work, throw exception otherwise
// 4. If no events: Return events + state
// Note: default intervals => 4 attempts (1st attempt + 3 retries)
let handleWith aggregateName decide build reBuild readEvents tryWriteEvents commandMessage wrongVersionRetryIntervals =
let handleCore =
async {
let streamKey = CommandHandlerUtils.createStreamKey aggregateName commandMessage
let! (historyEventReads, historyState) = CommandHandlerUtils.buildHistory reBuild readEvents streamKey
let events = decide commandMessage.Command historyState
if List.isEmpty events then
return (None, events, historyState)
else
let state = build historyState events
let! eventWritesResult =
CommandHandlerUtils.saveEvents tryWriteEvents historyEventReads events streamKey
return (Some eventWritesResult, events, state)
}
let acceptOutcome last _ (eventWritesResult, events, state) =
match eventWritesResult with
| None
| Some (Ok _) -> Return (events, state)
| Some (Error (UnexpectedStreamVersion _)) when not last -> TryAgain
| Some (Error e) -> e |> WriteEventsException |> raise
Retry.onValue acceptOutcome CommandHandlerUtils.getInvalidValueException wrongVersionRetryIntervals handleCore
let handle aggregateName decide build reBuild readEvents tryWriteEvents commandMessage =
handleWith
aggregateName
decide
build
reBuild
readEvents
tryWriteEvents
commandMessage
CommandHandlerUtils.defaultWrongVersionRetryIntervals
ValidatingGenericCommandHandler.fs
:
[<RequireQualifiedAccess>]
module My.EventSourcing.ValidatingGenericCommandHandler
open System
open My.FSharp.Resilience
open My.Infra.EventStore.Common
// 1. Build history state from readEvents
// 2. Decide new events based on command message + history state
// 3. If any events:
// - A. Events are wrapped with Ok
// - a. Try Save / Write new events to given stream (ie. command message) into the Event Store
// - b. Return events + state if writing events did work, throw exception otherwise
// - B. Return events wrapped with Error
// 4. If no events: Return (no) events + history state wrapped with Ok
// Note: default intervals => 4 attempts (1st attempt + 3 retries)
let handleWith aggregateName decide build reBuild readEvents tryWriteEvents commandMessage wrongVersionRetryIntervals =
let handleCore =
async {
let streamKey = CommandHandlerUtils.createStreamKey aggregateName commandMessage
let! (historyEventReads, historyState) = CommandHandlerUtils.buildHistory reBuild readEvents streamKey
let eventsResult = decide commandMessage.Command historyState
match eventsResult with
| Error events ->
return (None, Error events, historyState)
| Ok events when List.isEmpty events ->
return (None, Ok events, historyState)
| Ok events ->
let state = build historyState events
let! writeEventsResult =
CommandHandlerUtils.saveEvents tryWriteEvents historyEventReads events streamKey
return (Some writeEventsResult, Ok events, state)
}
let acceptOutcome last _ (eventWritesResult, eventsResult, state) =
match eventWritesResult, eventsResult with
| None, Error events -> events |> Error |> Return
| Some (Ok _), Ok events -> (events, state) |> Ok |> Return
| Some (Error (UnexpectedStreamVersion _)), Ok _ when not last -> TryAgain
| Some (Error e), Ok _ -> e |> WriteEventsException |> raise
| None, Ok _
| Some _, Error _ -> NotImplementedException() |> raise
Retry.onValue acceptOutcome CommandHandlerUtils.getInvalidValueException wrongVersionRetryIntervals handleCore
let handle aggregateName decide build reBuild readEvents tryWriteEvents message =
handleWith
aggregateName
decide
build
reBuild
readEvents
tryWriteEvents
message
CommandHandlerUtils.defaultWrongVersionRetryIntervals
```