Skip to content

Commit

Permalink
Add Commands and Handlers Tutorial and docs (#96)
Browse files Browse the repository at this point in the history
  • Loading branch information
bartelink committed Feb 17, 2019
1 parent 6938688 commit 22c9e24
Show file tree
Hide file tree
Showing 9 changed files with 696 additions and 13 deletions.
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ _NB at the present time, this project does not adhere strictly to Semantic Versi
### Added

- `Equinox.Projection.Kafka` consumer metrics emission, see [#94](https://github.com/jet/equinox/pull/94) @michaelliao5
- Add `samples/Tutorial` with `.fsx` files (see also related docs)

### Changed

Expand Down
312 changes: 307 additions & 5 deletions DOCUMENTATION.md

Large diffs are not rendered by default.

11 changes: 9 additions & 2 deletions Equinox.sln
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@

Microsoft Visual Studio Solution File, Format Version 12.00
# Visual Studio 15
VisualStudioVersion = 15.0.27110.0
# Visual Studio Version 16
VisualStudioVersion = 16.0.28531.58
MinimumVisualStudioVersion = 10.0.40219.1
Project("{6EC3EE1D-3C4E-46DD-8F32-0CC8E7565705}") = "Equinox", "src\Equinox\Equinox.fsproj", "{54CD058F-5B0A-4564-B732-1F6301E120AC}"
EndProject
Expand Down Expand Up @@ -67,6 +67,8 @@ Project("{6EC3EE1D-3C4E-46DD-8F32-0CC8E7565705}") = "Equinox.Projection.Codec",
EndProject
Project("{6EC3EE1D-3C4E-46DD-8F32-0CC8E7565705}") = "Equinox.Projection.Tests", "tests\Equinox.Projection.Tests\Equinox.Projection.Tests.fsproj", "{047F782D-DC37-4599-8FA0-F9B4D4C09C7B}"
EndProject
Project("{6EC3EE1D-3C4E-46DD-8F32-0CC8E7565705}") = "Tutorial", "samples\Tutorial\Tutorial.fsproj", "{D82AAB2E-7264-421A-A893-63A37E5F08B6}"
EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU
Expand Down Expand Up @@ -157,6 +159,10 @@ Global
{047F782D-DC37-4599-8FA0-F9B4D4C09C7B}.Debug|Any CPU.Build.0 = Debug|Any CPU
{047F782D-DC37-4599-8FA0-F9B4D4C09C7B}.Release|Any CPU.ActiveCfg = Release|Any CPU
{047F782D-DC37-4599-8FA0-F9B4D4C09C7B}.Release|Any CPU.Build.0 = Release|Any CPU
{D82AAB2E-7264-421A-A893-63A37E5F08B6}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{D82AAB2E-7264-421A-A893-63A37E5F08B6}.Debug|Any CPU.Build.0 = Debug|Any CPU
{D82AAB2E-7264-421A-A893-63A37E5F08B6}.Release|Any CPU.ActiveCfg = Release|Any CPU
{D82AAB2E-7264-421A-A893-63A37E5F08B6}.Release|Any CPU.Build.0 = Release|Any CPU
EndGlobalSection
GlobalSection(SolutionProperties) = preSolution
HideSolutionNode = FALSE
Expand All @@ -171,6 +177,7 @@ Global
{1B0D4568-96FD-4083-8520-CD537C0B2FF0} = {8F3EB30C-8BA3-4CC0-8361-0EA47C19ABB9}
{EC2EC658-3D85-44F3-AD2F-52AFCAFF8871} = {8CDE1CC3-8619-44DE-8B4D-4102CE476C35}
{8CDE1CC3-8619-44DE-8B4D-4102CE476C35} = {8F3EB30C-8BA3-4CC0-8361-0EA47C19ABB9}
{D82AAB2E-7264-421A-A893-63A37E5F08B6} = {8F3EB30C-8BA3-4CC0-8361-0EA47C19ABB9}
EndGlobalSection
GlobalSection(ExtensibilityGlobals) = postSolution
SolutionGuid = {177E1E7B-E275-4FC6-AE3C-2C651ECCF71E}
Expand Down
16 changes: 11 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

A unified programming model for event-sourced command processing and projections for stream-based stores.

Strives to be that while remaining a humble set of _libraries_ that _you compose_ into an architecture that fits your apps needs, not a final Architecture/object model/processing pipeline that's going to foist a one-size-fits-all model on you. You decide what facilities make sense for your context; Equinox covers those chosen infrastructural aspects without pulling in a cascade of dependencies from a jungle. (That's not to say we don't have plenty opinions and well polished patterns; we just try to confine the impact of that to [`samples`](samples) or [templates](https://github.com/jet/dotnet-templates), leaving judgement calls open for you to adjust as your app evolves).
Strives to be that while remaining a humble set of _libraries_ that _you compose_ into an architecture that fits your apps needs, not a final Architecture/object model/processing pipeline that's going to foist a one-size-fits-all model on you. You decide what facilities make sense for your context; Equinox covers those chosen infrastructural aspects without pulling in a cascade of dependencies from a jungle. (That's not to say we don't have plenty opinions and well polished patterns; we just try to confine the impact of that to [`samples`](/samples) or [templates](https://github.com/jet/dotnet-templates), leaving judgement calls open for you to adjust as your app evolves).

The design is informed by discussions, talks and countless hours of hard and thoughtful work invested into many previous systems, [frameworks](https://github.com/NEventStore), [samples](https://github.com/thinkbeforecoding/FsUno.Prod), [forks of samples](https://github.com/bartelink/FunDomain), the outstanding continuous work of the [EventStore](https://github.com/eventstore) founders and team and the wider [DDD-CQRS-ES](https://groups.google.com/forum/#!forum/dddcqrs) community. It would be unfair to single out even a small number of people despite the immense credit that is due. _If you're looking to learn more about and/or discuss Event Sourcing and it's myriad benefits, tradeoffs and pitfalls as you apply it to your Domain, look no further than the thriving 2000+ member community on the [DDD-CQRS-ES Slack](https://github.com/ddd-cqrs-es/slack-community); you'll get patient and impartial world class advice 24x7 (psst there's an [#equinox channel](https://ddd-cqrs-es.slack.com/messages/CF5J67H6Z) there where you can ask questions or offer feedback)._ ([invite link](https://t.co/MRxpx0rLH2))

Expand Down Expand Up @@ -54,7 +54,7 @@ While Equinox is implemented in F#, and F# is a great fit for writing event-sour
cinst eventstore-oss -y # where cinst is an invocation of the Chocolatey Package Installer on Windows
```

- For OSX, download the .pkg from https://eventstore.org/downloads/, click in Finder to launch the installer
- For OSX, download the `.pkg` from https://eventstore.org/downloads/, click in Finder to launch the installer

2. start the local EventStore instance

Expand Down Expand Up @@ -201,8 +201,8 @@ The components within this repository are delivered as a series of multi-targete

### Core libraries

- [![NuGet](https://img.shields.io/nuget/v/Equinox.svg)](https://www.nuget.org/packages/Equinox/) `Equinox.Handler`: Store-agnostic decision flow runner that manages the optimistic concurrency protocol. (depends on `Serilog` (but no specific Serilog sinks, i.e. you configure to emit to `NLog` etc))
- [![Codec NuGet](https://img.shields.io/nuget/v/Equinox.Codec.svg)](https://www.nuget.org/packages/Equinox.Codec/) `Equinox.Codec`: [a scheme for the serializing Events modelled as an F# Discriminated Union](https://eiriktsarpalis.wordpress.com/2018/10/30/a-contract-pattern-for-schemaless-datastores/) (depends on `TypeShape`, `Newtonsoft.Json >= 11.0.2` but can support any serializer) with the following capabilities:
- [![NuGet](https://img.shields.io/nuget/v/Equinox.svg)](https://www.nuget.org/packages/Equinox/) `Equinox[.Handler]`: Store-agnostic decision flow runner that manages the optimistic concurrency protocol. ([depends](https://www.fuget.org/packages/Equinox) on `Serilog` (but no specific Serilog sinks, i.e. you configure to emit to `NLog` etc))
- [![Codec NuGet](https://img.shields.io/nuget/v/Equinox.Codec.svg)](https://www.nuget.org/packages/Equinox.Codec/) `Equinox.Codec`: [a scheme for the serializing Events modelled as an F# Discriminated Union](https://eiriktsarpalis.wordpress.com/2018/10/30/a-contract-pattern-for-schemaless-datastores/) ([depends](https://www.fuget.org/packages/Equinox.Codec) on `TypeShape 6.*`, `Newtonsoft.Json >= 11.0.2` but can support any serializer) with the following capabilities:
- independent of any specific serializer
- allows tagging of F# Discriminated Union cases in a versionable manner with low-dependency `DataMember(Name=` tags using [TypeShape](https://github.com/eiriktsarpalis/TypeShape)'s [`UnionContractEncoder`](https://github.com/eiriktsarpalis/TypeShape/blob/master/tests/TypeShape.Tests/UnionContractTests.fs)

Expand All @@ -227,6 +227,7 @@ The components within this repository are delivered as a series of multi-targete
- [![Templates NuGet](https://img.shields.io/nuget/v/Equinox.Templates.svg)](https://www.nuget.org/packages/Equinox.Templates/) `Equinox.Templates`: [The templates repo](https://github.com/jet/dotnet-templates) has C# and F# sample apps. (Install via `dotnet new -i Equinox.Templates && dotnet new eqx --list`). See [the quickstart](quickstart) for examples of how to use it.
- [`samples/Store` (in this repo)](/samples/Store): Example domain types reflecting examples of how one applies Equinox to a diverse set of stream-based models
- [`samples/TodoBackend` (in this repo)](/samples/TodoBackend): Standard https://todobackend.com compliant backend
- [`samples/Tutorial` (in this repo)](/samples/Tutorial): Annotated `.fsx` files with sample Aggregate impls

## CONTRIBUTING

Expand Down Expand Up @@ -263,7 +264,9 @@ The `samples/` folder contains various further examples (some of the templates a
- acting as [Consumer Driven Contracts](https://martinfowler.com/articles/consumerDrivenContracts.html) to validate new and pin existing API designs.
- providing outline (not official and complete) guidance as to things that are valid to do in an application consuming Equinox components.
- to validate that each specific Storage implementation can fulfill the needs of each of the example Services/Aggregates/Applications. (_unfortunately this concern makes a lot of the DI wiring more complex than a real application should be; it's definitely a non-goal for every Equinox app to be able to switch between backends, even though that's very much possible to achieve._)
- provide sample scripts referenced in the Tutorial

<a name="TodoBackend"></a>
### [TODOBACKEND, see samples/TodoBackend](/samples/TodoBackend)

The repo contains a vanilla ASP.NET Core 2.1 implemention of [the well-known TodoBackend Spec](https://www.todobackend.com). **NB the implementation is largely dictated by spec; no architectural guidance expressed or implied ;)**. It can be run via:
Expand All @@ -288,6 +291,8 @@ While these things can of course be perfected through PRs, this is definitely no

For fun, there's a direct translation of the `InventoryItem` Aggregate and Command Handler from Greg Young's [`m-r`](https://github.com/gregoryyoung/m-r/tree/master/SimpleCQRS) demo project [as one could write it in F# using Equinox](https://github.com/jet/equinox/blob/master/samples/Store/Domain/InventoryItem.fs). NB any typical presentation of this example includes copious provisos and caveats about it being a toy example written almost a decade ago.

### [`samples/Tutorial` (in this repo)](/samples/Tutorial): Annotated `.fsx` files with sample aggregate implementations

## BENCHMARKS

A key facility of this repo is being able to run load tests, either in process against a nominated store, or via HTTP to a nominated instance of `samples/Web` ASP.NET Core host app. The following test suites are implemented at present:
Expand Down Expand Up @@ -355,6 +360,7 @@ The CLI can drive the Store and TodoBackend samples in the `samples/Web` ASP.NET
eqx run -t saveforlater -f 200 web

### run CosmosDb benchmark (when provisioned)

$env:EQUINOX_COSMOS_CONNECTION="AccountEndpoint=https://....;AccountKey=....=;"
$env:EQUINOX_COSMOS_DATABASE="equinox-test"
$env:EQUINOX_COSMOS_COLLECTION="equinox-test"
Expand Down Expand Up @@ -456,7 +462,7 @@ Yes, you have decisions to make; Equinox is not a panacea - there is no one size

### Is there a guide to building the simplest possible hello world "counter" sample, that simply counts with an add and a subtract event?

There's a skeleton one in [#56](https://github.com/jet/equinox/issues/56), but your best choices are probably to look at the `Aggregate.fs` and `Todo.fs` files emitted by [`dotnet new equinoxweb`](https://github.com/jet/dotnet-templates)
See the [Handler API Guide in DOCUMENTATION.md](DOCUMENTATION.md#api). An alternate way is to look at the `Todo.fs` files emitted by [`dotnet new equinoxweb`](https://github.com/jet/dotnet-templates) in the [QuickStart](#quickstart).

### OK, but you didn't answer my question, you just talked about stuff you wanted to talk about!

Expand Down
4 changes: 3 additions & 1 deletion build.proj
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,8 @@
<Exec Command="dotnet test samples/Store/Domain.Tests $(Cfg) $(TestOptions)" />
</Target>

<Target Name="Build" DependsOnTargets="VSTest;Pack" />
<Target Name="Build" DependsOnTargets="VSTest;Pack">
<Exec Command="dotnet build samples/Tutorial $(Cfg)" />
</Target>

</Project>
62 changes: 62 additions & 0 deletions samples/Tutorial/Counter.fsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
// Compile Tutorial.fsproj by either a) right-clicking or b) typing
// dotnet build samples/Tutorial before attempting to send this to FSI with Alt-Enter
#r "bin/Debug/netstandard2.0/Serilog.dll"
#r "bin/Debug/netstandard2.0/Serilog.Sinks.Console.dll"
#r "bin/Debug/netstandard2.0/Equinox.dll"
#r "bin/Debug/netstandard2.0/Equinox.MemoryStore.dll"

// Contributed by @voronoipotato

(* Events are things that have already happened,
they always exist in the past, and should always be past tense verbs*)

type Event =
| Incremented
| Decremented
| Cleared of int
(* A counter going up might clear to 0,
but a counter going down might clear to 100. *)

type State = State of int
(*Evolve takes the present state and one event and figures out the next state*)
let evolve state event =
match event, state with
| Incremented, State s -> State(s + 1)
| Decremented, State s -> State(s - 1)
| Cleared x , _ -> State x

(*fold is just folding the evolve function over all events to get the current state
It's equivalent to Linq's Aggregate function *)
let fold state events = Seq.fold evolve state events

(*Commands are the things we intend to happen, though they may not*)
type Command =
| Increment
| Decrement
| Clear of int

(* Decide consumes a command and the current state to decide what events actually happened.
This particular counter allows numbers from 0 to 100.*)
let decide command state =
match command with
| Increment ->
if state > 100 then [] else [Incremented]
| Decrement ->
if state <= 0 then [] else [Decremented]
| Clear i ->
if state = i then [] else [Cleared i]

type Handler(log, stream, ?maxAttempts) =
let inner = Equinox.Handler(log, stream, defaultArg maxAttempts 3)
member __.Execute command : Async<unit> =
inner.Transact(decide command)
member __.Read : Async<int> =
inner.Query id

type Service(log, resolveStream) =
let (|AggregateId|) (id : string) = Equinox.AggregateId("Counter", id)
let (|Stream|) (AggregateId id) = Handler(log, resolveStream id)
member __.Execute(Stream stream, command) : Async<unit> =
stream.Execute command
member __.Read(Stream stream) : Async<int> =
stream.Read
149 changes: 149 additions & 0 deletions samples/Tutorial/Favorites.fsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,149 @@
// Compile Tutorial.fsproj by either a) right-clicking or b) typing
// dotnet build samples/Tutorial before attempting to send this to FSI with Alt-Enter
#r "bin/Debug/netstandard2.0/Serilog.dll"
#r "bin/Debug/netstandard2.0/Serilog.Sinks.Console.dll"
#r "bin/Debug/netstandard2.0/Equinox.dll"
#r "bin/Debug/netstandard2.0/Equinox.MemoryStore.dll"

module List =
let contains x = List.exists ((=) x)

(*
* EVENTS
*)

(* Define the events that will be saved in the Stream *)

type Event =
| Added of string
| Removed of string

let initial : string list = []
let evolve state = function
| Added sku -> sku :: state
| Removed sku -> state |> List.filter (fun x -> x <> sku)
let fold s xs = Seq.fold evolve s xs

(* With the basic Events and `fold` defined, we have enough to build the state from the Events:- *)

let initialState = initial
//val initialState : string list = []

let favesCba = fold initialState [Added "a"; Added "b"; Added "c"]
//val favesCba : string list = ["c"; "b"; "a"]

(*
* COMMANDS
*)

(* Now we can build a State from the Events, we can interpret a Command in terms of how we'd represent that in the stream *)

type Command =
| Add of string
| Remove of string
let interpret command state =
match command with
| Add sku -> if state |> List.contains sku then [] else [Added sku]
| Remove sku -> if state |> List.contains sku |> not then [] else [Removed sku]

(* Note we don't yield events if they won't have a relevant effect - the interpret function makes the processing idempotent
if a retry of a command happens, it should not make a difference *)

let removeBEffect = interpret (Remove "b") favesCba
//val removeBEffect : Event list = [Removed "b"]

let favesCa = fold favesCba removeBEffect
// val favesCa : string list = ["c"; "a"]

let _removeBAgainEffect = interpret (Remove "b") favesCa
//val _removeBAgainEffect : Event list = []

(*
* HANDLER API
*)

(* Equinox.Handler provides low level functions against a Stream given
a) the fold function so it can maintain the state as we did above
b) a log to send metrics and store roundtrip info to
c) a maximum number of attempts to make if we clash with a conflicting write *)

type Handler(log, stream, ?maxAttempts) =
let inner = Equinox.Handler(log, stream, maxAttempts = defaultArg maxAttempts 2)
member __.Execute command : Async<unit> =
inner.Transact(interpret command)
member __.Read : Async<string list> =
inner.Query id

(* When we Execute a command, Equinox.Handler will use `fold` and `interpret` to Decide whether Events need to be written
Normally, we'll let failures percolate via exceptions, but not return a result (i.e. we don't say "your command caused 1 event") *)

// For now, write logs to the Console (in practice we'd connect it to a concrete log sink)
open Serilog
let log = LoggerConfiguration().WriteTo.Console(Serilog.Events.LogEventLevel.Debug).CreateLogger()

// related streams are termed a Category; Each client will have it's own Stream.
let categoryName = "Favorites"
let clientAFavoritesStreamId = Equinox.AggregateId(categoryName,"ClientA")

// For test purposes, we use the in-memory store
let store = Equinox.MemoryStore.VolatileStore()

// Each store has a Resolver which provides an IStream instance which binds to a specific stream in a specific store
// ... because the nature of the contract with the handler is such that the store hands over State, we also pass the `initial` and `fold` as we used above
let stream streamName = Equinox.MemoryStore.MemResolver(store, fold, initial).Resolve(streamName)

// We hand the streamId to the resolver
let clientAStream = stream clientAFavoritesStreamId
// ... and pass the stream to the Handler
let handler = Handler(log, clientAStream)

(* Run some commands *)

handler.Execute (Add "a") |> Async.RunSynchronously
handler.Execute (Add "b") |> Async.RunSynchronously
// Idempotency comes into play if we run it twice:
handler.Execute (Add "b") |> Async.RunSynchronously

(* Read the current state *)

handler.Read |> Async.RunSynchronously
// val it : string list = ["b"; "a"]

(*
* SERVICES
*)

(* Building a service to package Command Handling and related functions
No, this is not doing CQRS! *)

type Service(log, resolveStream) =
let streamHandlerFor (clientId: string) =
let aggregateId = Equinox.AggregateId("Favorites", clientId)
let stream = resolveStream aggregateId
Handler(log, stream)

member __.Favorite(clientId, sku) =
let stream = streamHandlerFor clientId
stream.Execute(Add sku)

member __.Unfavorite(clientId, skus) =
let stream = streamHandlerFor clientId
stream.Execute(Remove skus)

member __.List(clientId): Async<string list> =
let stream = streamHandlerFor clientId
stream.Read

let resolveStream = Equinox.MemoryStore.MemResolver(store, fold, initial).Resolve

let service = Service(log, resolveStream)

let client = "ClientB"
service.Favorite(client, "a") |> Async.RunSynchronously
service.Favorite(client, "b") |> Async.RunSynchronously
service.List(client) |> Async.RunSynchronously
// val it : string list = ["b"; "a"]

service.Unfavorite(client, "b") |> Async.RunSynchronously
service.List(client) |> Async.RunSynchronously
//val it : string list = ["a"]

0 comments on commit 22c9e24

Please sign in to comment.