diff --git a/DOCUMENTATION.md b/DOCUMENTATION.md index 6e516ac67..42f17f757 100755 --- a/DOCUMENTATION.md +++ b/DOCUMENTATION.md @@ -388,13 +388,11 @@ module Fold = module Snapshot = - let generate (state: State): Event = + let generate (state: State): Events.Event = Events.Snapshotted { ... } - let isOrigin = function - | Events.Snapshotted -> true - | _ -> false + let isOrigin = function Events.Snapshotted -> true | _ -> false let config = isOrigin, generate - let hydrate (e: Snapshotted): State = ... + let hydrate (e: Events.Snapshotted): State = ... let private evolve state = function | Events.Snapshotted e -> Snapshot.hydrate e @@ -402,29 +400,51 @@ module Fold = | Events.Y -> (state update) let fold = Array.fold evolve -let interpretX ... (state: Fold.State): Events list = ... +module Decisions = -type Decision = - | Accepted - | Failed of Reason + let interpretX ... (state: Fold.State): Events.Event[] = ... -let decideY ... (state: Fold.State): Decision * Events list = ... + type Decision = + | Accepted + | Failed of Reason + + let decideY ... (state: Fold.State): Decision * Events.Event[] = ... ``` - `interpret`, `decide` _and related input and output types / interfaces_ are public and top-level for use in unit tests (often unit tests will `open` the `module Fold` to use `initial` and `fold`) +In some cases, where surfacing the state in some way makes sense (it doesn't always; see [CQRS](https://learn.microsoft.com/en-us/azure/architecture/patterns/cqrs)), you'll have a: + +```fsharp +module Queries = + + type XzyInfo = { ... } + + let renderXyz (s: State): XzyInfo = + { ... } +``` + +The above functions can all be unit tested directly. All other tests should use the `Service` with a `MemoryStore` via the `member`s on that: + ```fsharp type Service internal (resolve: Id -> Equinox.Decider = let decider = resolve id - decider.Transact(interpretX command) + decider.Transact(Decisions.interpretX command) member _.Decide(id, inputs): Async = let decider = resolve id - decider.Transact(decideX inputs) + decider.Transact(Decisions.decideX inputs) + + member private _.Query(maxAge, render): Async = + let decider = resolve id + decider.Query(render, Equinox.LoadOption.AllowStale maxAge) + + member x.ReadCachedXyz(id): Async = + x.Query(TimeSpan.FromSeconds 10, Queries.renderXyz) let create category = Service(Stream.id >> Equinox.Decider.forStream Serilog.Log.Logger category) ``` @@ -1343,7 +1363,7 @@ which can then summarize the overall transaction. ### Idiomatic approach - composed method based on side-effect free functions There's an example of such a case in the -[Cart's Domain Service](https://github.com/jet/equinox/blob/master/samples/Store/Domain/Cart.fs#L128): +[Cart's Domain Service](https://github.com/jet/equinox/blob/master/samples/Store/Domain/Cart.fs#L106): ```fsharp let interpretMany fold interpreters (state: 'state): 'state * 'event[] = @@ -1372,9 +1392,7 @@ _NOTE: This is an example of an alternate approach provided as a counterpoint - there's no need to read it as the preceding approach is the recommended one is advised as a default strategy to use_ -As illustrated in [Cart's Domain -Service](https://github.com/jet/equinox/blob/master/samples/Store/Domain/Cart.fs#L99), -an alternate approach is to encapsulate the folding (Equinox in V1 exposed an +An alternate approach is to encapsulate the folding (Equinox in V1 exposed an interface that encouraged such patterns; this was removed in two steps, as code written using the idiomatic approach is [intrinsically simpler, even if it seems not as Easy](https://www.infoq.com/presentations/Simple-Made-Easy/) at @@ -2116,6 +2134,7 @@ Further information: - [DynamoDB Transactions: Use Cases and Examples](https://www.alexdebrie.com/posts/dynamodb-transactions/) by Alex DeBrie provides a thorough review of the `TransactWriteItems` facility (TL;DR: it's far more general than the stream level atomic transactions afforded by CosmosDB's Stored Procedures) +- while it doesn't provide deeper insight into the API from a usage perspective, [Distributed Transactions at Scale in Amazon DynamoDB](https://www.infoq.com/articles/amazon-dynamodb-transactions) is a great deep dive into how the facility is implemented. ### Differences in read and resync paths diff --git a/samples/Store/Domain.Tests/Domain.Tests.fsproj b/samples/Store/Domain.Tests/Domain.Tests.fsproj index 4081e8e92..01c3eeb8a 100644 --- a/samples/Store/Domain.Tests/Domain.Tests.fsproj +++ b/samples/Store/Domain.Tests/Domain.Tests.fsproj @@ -10,6 +10,7 @@ + diff --git a/samples/Store/Domain.Tests/LightTests.fs b/samples/Store/Domain.Tests/LightTests.fs new file mode 100644 index 000000000..f1dc9d637 --- /dev/null +++ b/samples/Store/Domain.Tests/LightTests.fs @@ -0,0 +1,27 @@ +module Domain.Tests.LightTests + +open Domain.Light +open Swensen.Unquote + +type Case = Off | On | After3Cycles +let establish = function + | Off -> initial + | On -> fold initial [| SwitchedOn |] + | After3Cycles -> [| for _ in 1..3 do SwitchedOn; SwitchedOff |] |> fold initial + +let run cmd state = + let events = decideSwitch cmd state + events, fold state events + +let [] props case cmd = + let state = establish case + let events, state = run cmd state + match case, cmd with + | Off, true -> events =! [| SwitchedOn |] + | Off, false -> events =! [||] + | On, true -> events =! [||] + | On, false -> events =! [| SwitchedOff |] + | After3Cycles, true -> events =! [| Broke |] + | After3Cycles, false -> events =! [||] + + [||] =! decideSwitch cmd state // all commands are idempotent diff --git a/samples/Store/Domain/Cart.fs b/samples/Store/Domain/Cart.fs index bc5b4ee3e..b6dc8630b 100644 --- a/samples/Store/Domain/Cart.fs +++ b/samples/Store/Domain/Cart.fs @@ -103,40 +103,6 @@ let interpret command (state: Fold.State) = | SyncItem (Context c, skuId, None, w) -> yield! maybePropChanges c skuId w |] -#if ACCUMULATOR -// This was once part of the core Equinox functionality, but was removed in https://github.com/jet/equinox/pull/184 -// it remains here solely to serve as an example; the PR details the considerations leading to this conclusion - -/// Maintains a rolling folded State while Accumulating Events pended as part of a decision flow -type Accumulator<'event, 'state>(fold: 'state -> 'event[] -> 'state, originState: 'state) = - let accumulated = ResizeArray<'event>() - - /// The Events that have thus far been pended via the `decide` functions `Execute`/`Decide`d during the course of this flow - member _.Accumulated: 'event[] = - accumulated.ToArray() - - /// The current folded State, based on the Stream's `originState` + any events that have been Accumulated during the the decision flow - member _.State: 'state = - accumulated |> fold originState - - /// Invoke a decision function, gathering the events (if any) that it decides are necessary into the `Accumulated` sequence - member x.Transact(interpret: 'state -> 'event[]): unit = - interpret x.State |> accumulated.AddRange - /// Invoke an Async decision function, gathering the events (if any) that it decides are necessary into the `Accumulated` sequence - member x.Transact(interpret: 'state -> Async<'event[]>): Async = async { - let! events = interpret x.State - accumulated.AddRange events } - /// Invoke a decision function, while also propagating a result yielded as the fst of an (result, events) pair - member x.Transact(decide: 'state -> 'result * 'event[]): 'result = - let result, newEvents = decide x.State - accumulated.AddRange newEvents - result - /// Invoke a decision function, while also propagating a result yielded as the fst of an (result, events) pair - member x.Transact(decide: 'state -> Async<'result * 'event[]>): Async<'result> = async { - let! result, newEvents = decide x.State - accumulated.AddRange newEvents - return result } -#else let interpretMany fold interpreters (state: 'state): 'state * 'event[] = let mutable state = state let events = [| @@ -145,21 +111,13 @@ let interpretMany fold interpreters (state: 'state): 'state * 'event[] = yield! events state <- fold state events |] state, events -#endif type Service internal (resolve: CartId -> Equinox.Decider) = member _.Run(cartId, optimistic, commands: Command seq, ?prepare): Async = let interpret state = async { match prepare with None -> () | Some prep -> do! prep -#if ACCUMULATOR - let acc = Accumulator(Fold.fold, state) - for cmd in commands do - acc.Transact(interpret cmd) - return acc.State, acc.Accumulated } -#else return interpretMany Fold.fold (Seq.map interpret commands) state } -#endif let decider = resolve cartId let opt = if optimistic then Equinox.LoadOption.AnyCachedValue else Equinox.LoadOption.RequireLoad decider.Transact(interpret, opt) diff --git a/samples/Store/Domain/Domain.fsproj b/samples/Store/Domain/Domain.fsproj index 001fbf3c4..01e36ac4b 100644 --- a/samples/Store/Domain/Domain.fsproj +++ b/samples/Store/Domain/Domain.fsproj @@ -12,6 +12,7 @@ + diff --git a/samples/Store/Domain/Light.fs b/samples/Store/Domain/Light.fs new file mode 100644 index 000000000..6862cfe64 --- /dev/null +++ b/samples/Store/Domain/Light.fs @@ -0,0 +1,33 @@ +/// By Jérémie Chassaing / @thinkb4coding +/// https://github.com/dddeu/dddeu20-talks-jeremie-chassaing-functional-event-sourcing/blob/main/EventSourcing.fsx#L52-L84 +module Domain.Light + +type Event = + | SwitchedOn + | SwitchedOff + | Broke +type State = + | Working of CurrentState + | Broken +and CurrentState = { on: bool; remainingUses: int } +let initial = Working { on = false; remainingUses = 3 } +let private evolve s e = + match s with + | Broken -> s + | Working s -> + match e with + | SwitchedOn -> Working { on = true; remainingUses = s.remainingUses - 1 } + | SwitchedOff -> Working { s with on = false } + | Broke -> Broken +let fold = Array.fold evolve + +let decideSwitch (on: bool) s = [| + match s with + | Broken -> () + | Working { on = true } -> + if not on then + SwitchedOff + | Working { on = false; remainingUses = r } -> + if on then + if r = 0 then Broke + else SwitchedOn |]