Skip to content

Commit

Permalink
Add tutorial
Browse files Browse the repository at this point in the history
  • Loading branch information
bartelink committed Feb 17, 2019
1 parent 2e173a1 commit 7a07086
Show file tree
Hide file tree
Showing 7 changed files with 675 additions and 3 deletions.
299 changes: 299 additions & 0 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
3 changes: 2 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -264,6 +264,7 @@ The `samples/` folder contains various further examples (some of the templates a
- 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._)

<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 Down Expand Up @@ -456,7 +457,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
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 7a07086

Please sign in to comment.