Skip to content

Commit

Permalink
WIP
Browse files Browse the repository at this point in the history
  • Loading branch information
bartelink committed Nov 21, 2021
1 parent 50dc5fd commit 21d40a2
Show file tree
Hide file tree
Showing 7 changed files with 235 additions and 0 deletions.
10 changes: 10 additions & 0 deletions EventSourcing.NetCore.sln
Expand Up @@ -217,6 +217,10 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Core.Api.Testing", "Core.Ap
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "ECommerce.Core", "Sample\EventStoreDB\Simple\ECommerce.Core\ECommerce.Core.csproj", "{D3351193-F63A-43F1-BB70-C9F4D25887CA}"
EndProject
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "ECommerce.Equinox", "ECommerce.Equinox", "{5CF0173B-EAC6-4100-8F5A-F1966066E87D}"
EndProject
Project("{F2A71F9B-5D33-465A-A702-920D77279786}") = "ECommerce.Domain", "Sample\ECommerce.Equinox\ECommerce.Domain\ECommerce.Domain.fsproj", "{7C22079D-C359-40DE-8E5F-1DDD9CDD06DD}"
EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU
Expand Down Expand Up @@ -491,6 +495,10 @@ Global
{D3351193-F63A-43F1-BB70-C9F4D25887CA}.Debug|Any CPU.Build.0 = Debug|Any CPU
{D3351193-F63A-43F1-BB70-C9F4D25887CA}.Release|Any CPU.ActiveCfg = Release|Any CPU
{D3351193-F63A-43F1-BB70-C9F4D25887CA}.Release|Any CPU.Build.0 = Release|Any CPU
{7C22079D-C359-40DE-8E5F-1DDD9CDD06DD}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{7C22079D-C359-40DE-8E5F-1DDD9CDD06DD}.Debug|Any CPU.Build.0 = Debug|Any CPU
{7C22079D-C359-40DE-8E5F-1DDD9CDD06DD}.Release|Any CPU.ActiveCfg = Release|Any CPU
{7C22079D-C359-40DE-8E5F-1DDD9CDD06DD}.Release|Any CPU.Build.0 = Release|Any CPU
EndGlobalSection
GlobalSection(SolutionProperties) = preSolution
HideSolutionNode = FALSE
Expand Down Expand Up @@ -580,6 +588,8 @@ Global
{36C7CF36-254A-48D5-8181-9196DB1A034B} = {11DD4963-5BB4-4E1B-9475-8EB10C822BFC}
{E96D4B8C-AF32-4434-BB5D-5C88675DC084} = {0570E45A-2EB6-4C4C-84E4-2C80E1FECEB5}
{D3351193-F63A-43F1-BB70-C9F4D25887CA} = {11DD4963-5BB4-4E1B-9475-8EB10C822BFC}
{5CF0173B-EAC6-4100-8F5A-F1966066E87D} = {A7186B6B-D56D-4AEF-B6B7-FAA827764C34}
{7C22079D-C359-40DE-8E5F-1DDD9CDD06DD} = {5CF0173B-EAC6-4100-8F5A-F1966066E87D}
EndGlobalSection
GlobalSection(ExtensibilityGlobals) = postSolution
SolutionGuid = {A5F55604-2FF3-43B7-B657-4F18E6E95D3B}
Expand Down
32 changes: 32 additions & 0 deletions Sample/ECommerce.Equinox/ECommerce.Domain/Config.fs
@@ -0,0 +1,32 @@
module ECommerce.Domain.Config

let log = Serilog.Log.ForContext("isMetric", true)
let createDecider stream = Equinox.Decider(log, stream, maxAttempts = 3)

module Memory =

let create codec initial fold store =
Equinox.MemoryStore.MemoryStoreCategory(store, codec, fold, initial)

module Cosmos =

let private createCached codec initial fold accessStrategy (context, cache) =
let cacheStrategy = Equinox.CosmosStore.CachingStrategy.SlidingWindow (cache, System.TimeSpan.FromMinutes 20.)
Equinox.CosmosStore.CosmosStoreCategory(context, codec, fold, initial, cacheStrategy, accessStrategy)

let createUnoptimized codec initial fold (context, cache) =
let accessStrategy = Equinox.CosmosStore.AccessStrategy.Unoptimized
createCached codec initial fold accessStrategy (context, cache)

// let createSnapshotted codec initial fold (isOrigin, toSnapshot) (context, cache) =
// let accessStrategy = Equinox.CosmosStore.AccessStrategy.Snapshot (isOrigin, toSnapshot)
// createCached codec initial fold accessStrategy (context, cache)
//
let createRollingState codec initial fold toSnapshot (context, cache) =
let accessStrategy = Equinox.CosmosStore.AccessStrategy.RollingState toSnapshot
createCached codec initial fold accessStrategy (context, cache)

[<NoComparison; NoEquality; RequireQualifiedAccess>]
type Store<'t> =
| Memory of Equinox.MemoryStore.VolatileStore<'t>
| Cosmos of Equinox.CosmosStore.CosmosStoreContext * Equinox.Core.ICache
23 changes: 23 additions & 0 deletions Sample/ECommerce.Equinox/ECommerce.Domain/ECommerce.Domain.fsproj
@@ -0,0 +1,23 @@
<Project Sdk="Microsoft.NET.Sdk">

<PropertyGroup>
<TargetFramework>net5.0</TargetFramework>
<GenerateDocumentationFile>true</GenerateDocumentationFile>
</PropertyGroup>

<ItemGroup>
<Compile Include="Config.fs" />
<Compile Include="Types.fs" />
<Compile Include="PricedProductItem.fs" />
<Compile Include="IProductPriceCalculator.fs" />
<Compile Include="ShoppingCart.fs" />
</ItemGroup>

<ItemGroup>
<PackageReference Include="Equinox.CosmosStore" Version="3.0.5" />
<PackageReference Include="Equinox.MemoryStore" Version="3.0.5" />
<PackageReference Include="FsCodec.NewtonsoftJson" Version="2.2.2" />
<PackageReference Include="Propulsion" Version="2.11.0" />
</ItemGroup>

</Project>
@@ -0,0 +1,16 @@
namespace ECommerce.Domain

type IProductPriceCalculator =

abstract Calculate : ProductItem -> PricedProductItem

type RandomProductPriceCalculator() =

let productPrices = System.Collections.Concurrent.ConcurrentDictionary<ProductId, decimal>()

interface IProductPriceCalculator with
override _.Calculate(productItem : ProductItem) =
let r = System.Random()
let calc _ = (r.NextDouble() |> decimal) * 100m
let price : decimal = productPrices.GetOrAdd(productItem.productId, calc)
PricedProductItem.From(productItem, price)
26 changes: 26 additions & 0 deletions Sample/ECommerce.Equinox/ECommerce.Domain/PricedProductItem.fs
@@ -0,0 +1,26 @@
namespace ECommerce.Domain

open System

type ProductItem = { productId : ProductId; quantity : int }

type PricedProductItem = { productItem : ProductItem; unitPrice : decimal } with

member x.ProductId = x.productItem.productId
member x.Quantity = x.productItem.quantity
member x.TotalPrice = decimal x.Quantity * x.unitPrice

static member From(productItem, ?unitPrice : decimal) =
match unitPrice with
| None -> nullArg (nameof(unitPrice))
| Some price when price <= 0m -> raise <| ArgumentOutOfRangeException(nameof unitPrice, "Unit price has to be positive number")
| Some price -> { productItem = productItem; unitPrice = price }

member x.MatchesProductAndUnitPrice(pricedProductItem : PricedProductItem) =
x.ProductId = pricedProductItem.ProductId && x.unitPrice = pricedProductItem.unitPrice

member x.MergeWith(productItem : PricedProductItem) =
if x.ProductId <> productItem.ProductId then raise <| ArgumentException "Product ids do not match."
if x.unitPrice <> productItem.unitPrice then raise <| ArgumentException "Product unit prices do not match."
// TODO fix bug in source: new ProductItem(ProductId, productItem.Quantity + productItem.Quantity),
{ productItem = { productId = x.ProductId; quantity = x.Quantity + productItem.Quantity }; unitPrice = x.unitPrice }
103 changes: 103 additions & 0 deletions Sample/ECommerce.Equinox/ECommerce.Domain/ShoppingCart.fs
@@ -0,0 +1,103 @@
module ECommerce.Domain.ShoppingCart

let [<Literal>] Category = "ShoppingCart"

let streamName = CartId.toString >> FsCodec.StreamName.create Category

module Events =

type Event =
| Initialized of {| clientId : ClientId |}
| ItemAdded of {| productId : ProductId; quantity : int; unitPrice : decimal |}
| ItemRemoved of {| productId : ProductId; (*; quantity : int;*) unitPrice : decimal |}
| Confirmed of {| confirmedAt : System.DateTimeOffset |}
interface TypeShape.UnionContract.IUnionContract
let codec = FsCodec.NewtonsoftJson.Codec.Create<Event>()

module Fold =

type Status = Pending | Confirmed | Cancelled
type State =
{ clientId : ClientId option
status : Status; items : PricedProductItem array
confirmedAt : System.DateTimeOffset option }
let initial = { clientId = None; status = Status.Pending; items = Array.empty; confirmedAt = None }
let isClosed (s : State) = match s.status with Confirmed | Cancelled -> true | Pending -> false
module ItemList =
let keys (x : PricedProductItem) = x.productItem.productId, x.unitPrice
let add (productId, price, quantity) (current : PricedProductItem seq) =
let newItemKeys = productId, price
let mkItem (productId, price, quantity) = { productItem = { productId = productId; quantity = quantity }; unitPrice = price }
let mutable merged = false
[| for x in current do
if newItemKeys = keys x then
mkItem (productId, price, x.productItem.quantity + quantity)
merged <- true
else
x
if not merged then
mkItem (productId, price, quantity) |]
let remove (productId, price) (current : PricedProductItem[]) =
current |> Array.where (fun x -> keys x <> (productId, price))
let private evolve s = function
| Events.Initialized e -> { s with clientId = Some e.clientId }
| Events.ItemAdded e -> { s with items = s.items |> ItemList.add (e.productId, e.unitPrice, e.quantity) }
| Events.ItemRemoved e -> { s with items = s.items |> ItemList.remove (e.productId, e.unitPrice) }
| Events.Confirmed e -> { s with confirmedAt = Some e.confirmedAt }
let fold = Seq.fold evolve

let decideInitialize clientId (s : Fold.State) =
if s.clientId <> None then []
else [ Events.Initialized {| clientId = clientId |}]

let decideAdd calculatePrice productId quantity = function
| s when Fold.isClosed s -> invalidOp $"Adding product item for cart in '%A{s.status}' status is not allowed."
| _ ->
let price = calculatePrice (productId, quantity)
[ Events.ItemAdded {| productId = productId; unitPrice = price; quantity = quantity |} ]

let decideRemove (productId, price) = function
| s when Fold.isClosed s -> invalidOp $"Removing product item for cart in '%A{s.status}' status is not allowed."
| _ ->
[ Events.ItemRemoved {| productId = productId; unitPrice = price |} ]

let decideConfirm at = function
| s when Fold.isClosed s -> []
| _ -> [ Events.Confirmed {| confirmedAt = at |} ]

type Service(resolve : CartId -> Equinox.Decider<Events.Event, Fold.State>, calculatePrice : ProductId * int -> decimal) =

member _.Initialize(cartId, clientId) =
let decider = resolve cartId
decider.Transact(decideInitialize clientId)

member _.Add(cartId, productId, quantity) =
let decider = resolve cartId
decider.Transact(decideAdd calculatePrice productId quantity)

// TODO fix in Remove: throw new InvalidOperationException($"Adding product item for cart in '{shoppingCart.Status}' status is not allowed.");

member _.Remove(cartId, productId, price) =
let decider = resolve cartId
decider.Transact(decideRemove (productId, price))

member _.Confirm(cartId, at) =
let decider = resolve cartId
decider.Transact(decideConfirm at)

module Config =

let calculatePrice (pricer : IProductPriceCalculator) (productId, quantity) =
let priced = pricer.Calculate( { productId = productId; quantity = quantity })
priced.unitPrice

let private resolveStream = function
| Config.Store.Memory store ->
let cat = Config.Memory.create Events.codec Fold.initial Fold.fold store
cat.Resolve
| Config.Store.Cosmos (context, cache) ->
let cat = Config.Cosmos.createUnoptimized Events.codec Fold.initial Fold.fold (context, cache)
cat.Resolve
let private resolveDecider store = streamName >> resolveStream store >> Config.createDecider
let create (pricer : IProductPriceCalculator) store =
Service(resolveDecider store, calculatePrice pricer)
25 changes: 25 additions & 0 deletions Sample/ECommerce.Equinox/ECommerce.Domain/Types.fs
@@ -0,0 +1,25 @@
namespace ECommerce.Domain

open FSharp.UMX
open System

type ProductId = Guid<productId>
and [<Measure>] productId
module ProductId =
let toString (x : ProductId) : string = (UMX.untag x).ToString("N")
let parse (value : Guid) : ProductId = %value

type ClientId = Guid<clientId>
and [<Measure>] clientId
module ClientId =
let toString (x : ClientId) : string = (UMX.untag x).ToString("N")
let parse (value : Guid) : ClientId = %value

type CartId = Guid<cartId>
and [<Measure>] cartId
module CartId =
let toString (x : CartId) : string = (UMX.untag x).ToString("N")
let parse (value : Guid Nullable) : CartId =
if not value.HasValue || value.Value = Guid.Empty then raise <| ArgumentOutOfRangeException(nameof value)
%value.Value

0 comments on commit 21d40a2

Please sign in to comment.