Skip to content

Commit

Permalink
docs: Updates based on DOs and DONTs (#429)
Browse files Browse the repository at this point in the history
* update module Aggregate docs
* Add Queries section
* Add DDB article link
* Add Light example
* Remove Accumulator
  • Loading branch information
bartelink authored Dec 15, 2023
1 parent 08382c7 commit 39980de
Show file tree
Hide file tree
Showing 6 changed files with 97 additions and 58 deletions.
51 changes: 35 additions & 16 deletions DOCUMENTATION.md
Original file line number Diff line number Diff line change
Expand Up @@ -388,43 +388,63 @@ 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
| Events.X -> (state update)
| 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<Events.Event, Fold.State) = ...`
member _.Execute(id, command): Async<unit> =
let decider = resolve id
decider.Transact(interpretX command)
decider.Transact(Decisions.interpretX command)
member _.Decide(id, inputs): Async<Decision> =
let decider = resolve id
decider.Transact(decideX inputs)
decider.Transact(Decisions.decideX inputs)
member private _.Query(maxAge, render): Async<Queries.XyzInfo> =
let decider = resolve id
decider.Query(render, Equinox.LoadOption.AllowStale maxAge)
member x.ReadCachedXyz(id): Async<Queries.XyzInfo> =
x.Query(TimeSpan.FromSeconds 10, Queries.renderXyz)
let create category = Service(Stream.id >> Equinox.Decider.forStream Serilog.Log.Logger category)
```
Expand Down Expand Up @@ -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[] =
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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

Expand Down
1 change: 1 addition & 0 deletions samples/Store/Domain.Tests/Domain.Tests.fsproj
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
<Compile Include="ContactPreferencesTests.fs" />
<Compile Include="FavoritesTests.fs" />
<Compile Include="SavedForLaterTests.fs" />
<Compile Include="LightTests.fs" />
</ItemGroup>

<ItemGroup>
Expand Down
27 changes: 27 additions & 0 deletions samples/Store/Domain.Tests/LightTests.fs
Original file line number Diff line number Diff line change
@@ -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 [<FsCheck.Xunit.Property>] 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
42 changes: 0 additions & 42 deletions samples/Store/Domain/Cart.fs
Original file line number Diff line number Diff line change
Expand Up @@ -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<unit> = 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 = [|
Expand All @@ -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<Events.Event, Fold.State>) =

member _.Run(cartId, optimistic, commands: Command seq, ?prepare): Async<Fold.State> =
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)
Expand Down
1 change: 1 addition & 0 deletions samples/Store/Domain/Domain.fsproj
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
<Compile Include="SavedForLater.fs" />
<Compile Include="InventoryItem.fs" />
<Compile Include="Retries.fs" />
<Compile Include="Light.fs" />
</ItemGroup>

<ItemGroup>
Expand Down
33 changes: 33 additions & 0 deletions samples/Store/Domain/Light.fs
Original file line number Diff line number Diff line change
@@ -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 |]

0 comments on commit 39980de

Please sign in to comment.