From 31d511898b00d7380a644bb5a23cd637a449b78c Mon Sep 17 00:00:00 2001 From: Oskar Dudycz Date: Mon, 28 Aug 2023 10:03:25 +0200 Subject: [PATCH] Added example of using Marten with Decider pattern and Evolve function --- .../Immutable/Solution4/BusinessLogicTests.cs | 94 +++++++++++++++++++ .../Solution4/DocumentSessionExtensions.cs | 28 ++++++ .../Immutable/Solution4/ShoppingCart.cs | 72 ++++++++++++++ .../Solution4/ShoppingCartDecider.cs | 78 +++++++++++++++ .../Solution4/ShoppingCartValueObjects.cs | 64 +++++++++++++ 5 files changed, 336 insertions(+) create mode 100644 Workshops/IntroductionToEventSourcing/Solved/08-BusinessLogic.Marten/Immutable/Solution4/BusinessLogicTests.cs create mode 100644 Workshops/IntroductionToEventSourcing/Solved/08-BusinessLogic.Marten/Immutable/Solution4/DocumentSessionExtensions.cs create mode 100644 Workshops/IntroductionToEventSourcing/Solved/08-BusinessLogic.Marten/Immutable/Solution4/ShoppingCart.cs create mode 100644 Workshops/IntroductionToEventSourcing/Solved/08-BusinessLogic.Marten/Immutable/Solution4/ShoppingCartDecider.cs create mode 100644 Workshops/IntroductionToEventSourcing/Solved/08-BusinessLogic.Marten/Immutable/Solution4/ShoppingCartValueObjects.cs diff --git a/Workshops/IntroductionToEventSourcing/Solved/08-BusinessLogic.Marten/Immutable/Solution4/BusinessLogicTests.cs b/Workshops/IntroductionToEventSourcing/Solved/08-BusinessLogic.Marten/Immutable/Solution4/BusinessLogicTests.cs new file mode 100644 index 000000000..ef109f431 --- /dev/null +++ b/Workshops/IntroductionToEventSourcing/Solved/08-BusinessLogic.Marten/Immutable/Solution4/BusinessLogicTests.cs @@ -0,0 +1,94 @@ +using FluentAssertions; +using IntroductionToEventSourcing.BusinessLogic.Tools; +using Xunit; + +namespace IntroductionToEventSourcing.BusinessLogic.Immutable.Solution4; + +using static ShoppingCart; +using static ShoppingCartCommand; + +public class BusinessLogicTests: MartenTest +{ + [Fact] + public async Task GettingState_ForSequenceOfEvents_ShouldSucceed() + { + var shoppingCartId = ShoppingCartId.From(Guid.NewGuid()); + var clientId = ClientId.From(Guid.NewGuid()); + var shoesId = ProductId.From(Guid.NewGuid()); + var tShirtId = ProductId.From(Guid.NewGuid()); + + var one = ProductQuantity.From(1); + var two = ProductQuantity.From(2); + + var twoPairsOfShoes = new ProductItem(shoesId, two); + var pairOfShoes = new ProductItem(shoesId, one); + var tShirt = new ProductItem(tShirtId, one); + + var shoesPrice = ProductPrice.From(100); + var tShirtPrice = ProductPrice.From(50); + + var pricedPairOfShoes = new PricedProductItem(shoesId, one, shoesPrice); + var pricedTwoPairsOfShoes = new PricedProductItem(shoesId, two, shoesPrice); + var pricedTShirt = new PricedProductItem(tShirtId, one, tShirtPrice); + + await DocumentSession.Decide( + shoppingCartId, + new Open(shoppingCartId, clientId, DateTimeOffset.Now), + CancellationToken.None + ); + + // Add two pairs of shoes + await DocumentSession.Decide( + shoppingCartId, + new AddProductItem(shoppingCartId, pricedTwoPairsOfShoes), + CancellationToken.None + ); + + // Add T-Shirt + await DocumentSession.Decide( + shoppingCartId, + new AddProductItem(shoppingCartId, pricedTShirt), + CancellationToken.None + ); + + // Remove pair of shoes + await DocumentSession.Decide( + shoppingCartId, + new RemoveProductItem(shoppingCartId, pricedPairOfShoes), + CancellationToken.None + ); + + + var pendingShoppingCart = + await DocumentSession.Get(shoppingCartId.Value, CancellationToken.None) as Pending; + + pendingShoppingCart.Should().NotBeNull(); + pendingShoppingCart!.ProductItems.Should().HaveCount(3); + + pendingShoppingCart.ProductItems[0].Should() + .Be((pricedTwoPairsOfShoes.ProductId, pricedTwoPairsOfShoes.Quantity.Value)); + pendingShoppingCart.ProductItems[1].Should().Be((pricedTShirt.ProductId, pricedTShirt.Quantity.Value)); + pendingShoppingCart.ProductItems[2].Should().Be((pairOfShoes.ProductId, -pairOfShoes.Quantity.Value)); + + // Confirm + await DocumentSession.Decide( + shoppingCartId, + new Confirm(shoppingCartId, DateTimeOffset.Now), + CancellationToken.None + ); + + // Cancel + var exception = await Record.ExceptionAsync(() => + DocumentSession.Decide( + shoppingCartId, + new Cancel(shoppingCartId, DateTimeOffset.Now), + CancellationToken.None + ) + ); + exception.Should().BeOfType(); + + var shoppingCart = await DocumentSession.Get(shoppingCartId.Value, CancellationToken.None); + + shoppingCart.Should().BeOfType(); + } +} diff --git a/Workshops/IntroductionToEventSourcing/Solved/08-BusinessLogic.Marten/Immutable/Solution4/DocumentSessionExtensions.cs b/Workshops/IntroductionToEventSourcing/Solved/08-BusinessLogic.Marten/Immutable/Solution4/DocumentSessionExtensions.cs new file mode 100644 index 000000000..c128e57d6 --- /dev/null +++ b/Workshops/IntroductionToEventSourcing/Solved/08-BusinessLogic.Marten/Immutable/Solution4/DocumentSessionExtensions.cs @@ -0,0 +1,28 @@ +using Marten; + +namespace IntroductionToEventSourcing.BusinessLogic.Immutable.Solution4; + +public static class DocumentSessionExtensions +{ + public static async Task Get( + this IDocumentSession session, + Guid id, + CancellationToken cancellationToken = default + ) where TEntity : class + { + var entity = await session.Events.AggregateStreamAsync(id, token: cancellationToken); + + return entity ?? throw new InvalidOperationException($"Entity with id {id} was not found"); + } + + public static Task Decide( + this IDocumentSession session, + Func decide, + Func getDefault, + Guid streamId, + TCommand command, + CancellationToken ct = default + ) where TEntity : class => + session.Events.WriteToAggregate(streamId, stream => + stream.AppendMany(decide(command, stream.Aggregate ?? getDefault()).Cast().ToArray()), ct); +} diff --git a/Workshops/IntroductionToEventSourcing/Solved/08-BusinessLogic.Marten/Immutable/Solution4/ShoppingCart.cs b/Workshops/IntroductionToEventSourcing/Solved/08-BusinessLogic.Marten/Immutable/Solution4/ShoppingCart.cs new file mode 100644 index 000000000..5655055d0 --- /dev/null +++ b/Workshops/IntroductionToEventSourcing/Solved/08-BusinessLogic.Marten/Immutable/Solution4/ShoppingCart.cs @@ -0,0 +1,72 @@ +namespace IntroductionToEventSourcing.BusinessLogic.Immutable.Solution4; + +using static ShoppingCartEvent; + +public abstract record ShoppingCartEvent +{ + public record Opened(ClientId ClientId, DateTimeOffset OpenedAt): ShoppingCartEvent; + + public record ProductItemAdded(PricedProductItem ProductItem): ShoppingCartEvent; + + public record ProductItemRemoved(PricedProductItem ProductItem): ShoppingCartEvent; + + public record Confirmed(DateTimeOffset ConfirmedAt): ShoppingCartEvent; + + public record Canceled(DateTimeOffset CanceledAt): ShoppingCartEvent; +} + +public record ShoppingCart +{ + public record Empty: ShoppingCart; + + public record Pending((ProductId ProductId, int Quantity)[] ProductItems): ShoppingCart + { + public bool HasEnough(PricedProductItem productItem) => + ProductItems + .Where(pi => pi.ProductId == productItem.ProductId) + .Sum(pi => pi.Quantity) >= productItem.Quantity.Value; + + public bool HasItems { get; } = + ProductItems.Sum(pi => pi.Quantity) <= 0; + } + + public record Closed: ShoppingCart; + + public ShoppingCart Apply(ShoppingCartEvent @event) => + @event switch + { + Opened => + new Pending(Array.Empty<(ProductId ProductId, int Quantity)>()), + + ProductItemAdded (var (productId, quantity, _)) => + this is Pending pending + ? pending with + { + ProductItems = pending.ProductItems + .Concat(new[] { (productId, quantity.Value) }) + .ToArray() + } + : this, + + ProductItemRemoved (var (productId, quantity, _)) => + this is Pending pending + ? pending with + { + ProductItems = pending.ProductItems + .Concat(new[] { (ProductId: productId, -quantity.Value) }) + .ToArray() + } + : this, + + Confirmed => + this is Pending ? new Closed() : this, + + Canceled => + this is Pending ? new Closed() : this, + + _ => this + }; + + public Guid Id { get; set; } // Marten unfortunately forces you to have Id + private ShoppingCart() { } // Not to allow inheritance +} diff --git a/Workshops/IntroductionToEventSourcing/Solved/08-BusinessLogic.Marten/Immutable/Solution4/ShoppingCartDecider.cs b/Workshops/IntroductionToEventSourcing/Solved/08-BusinessLogic.Marten/Immutable/Solution4/ShoppingCartDecider.cs new file mode 100644 index 000000000..117465a46 --- /dev/null +++ b/Workshops/IntroductionToEventSourcing/Solved/08-BusinessLogic.Marten/Immutable/Solution4/ShoppingCartDecider.cs @@ -0,0 +1,78 @@ +using Marten; + +namespace IntroductionToEventSourcing.BusinessLogic.Immutable.Solution4; + +using static ShoppingCart; +using static ShoppingCartEvent; +using static ShoppingCartCommand; + +public abstract record ShoppingCartCommand +{ + public record Open(ShoppingCartId ShoppingCartId, ClientId ClientId, DateTimeOffset Now): ShoppingCartCommand; + + public record AddProductItem(ShoppingCartId ShoppingCartId, PricedProductItem ProductItem): ShoppingCartCommand; + + public record RemoveProductItem(ShoppingCartId ShoppingCartId, PricedProductItem ProductItem): ShoppingCartCommand; + + public record Confirm(ShoppingCartId ShoppingCartId, DateTimeOffset Now): ShoppingCartCommand; + + public record Cancel(ShoppingCartId ShoppingCartId, DateTimeOffset Now): ShoppingCartCommand; +} + +// Value Objects +public static class ShoppingCartService +{ + public static ShoppingCartEvent Decide( + ShoppingCartCommand command, + ShoppingCart state + ) => + command switch + { + Open open => Handle(open), + AddProductItem addProduct => Handle(addProduct, state.EnsureIsPending()), + RemoveProductItem removeProduct => Handle(removeProduct, state.EnsureIsPending()), + Confirm confirm => Handle(confirm, state.EnsureIsPending()), + Cancel cancel => Handle(cancel, state.EnsureIsPending()), + _ => throw new InvalidOperationException($"Cannot handle {command.GetType().Name} command") + }; + + private static Opened Handle(Open command) => + new Opened(command.ClientId, command.Now); + + private static ProductItemAdded Handle(AddProductItem command, Pending shoppingCart) => + new ProductItemAdded(command.ProductItem); + + private static ProductItemRemoved Handle(RemoveProductItem command, Pending shoppingCart) => + shoppingCart.HasEnough(command.ProductItem) + ? new ProductItemRemoved(command.ProductItem) + : throw new InvalidOperationException("Not enough product items to remove."); + + private static Confirmed Handle(Confirm command, Pending shoppingCart) => + shoppingCart.HasItems + ? new Confirmed(DateTime.UtcNow) + : throw new InvalidOperationException("Shopping cart is empty!"); + + private static Canceled Handle(Cancel command, Pending shoppingCart) => + new Canceled(DateTime.UtcNow); + + private static Pending EnsureIsPending(this ShoppingCart shoppingCart) => + shoppingCart as Pending ?? throw new InvalidOperationException( + $"Invalid operation for '{shoppingCart.GetType().Name}' shopping card."); +} + +public static class ShoppingCartDocumentSessionExtensions +{ + public static Task Decide( + this IDocumentSession session, + ShoppingCartId streamId, + ShoppingCartCommand command, + CancellationToken ct = default + ) => + session.Decide( + (c, s) => new[] { ShoppingCartService.Decide(c, s) }, + () => new Empty(), + streamId.Value, + command, + ct + ); +} diff --git a/Workshops/IntroductionToEventSourcing/Solved/08-BusinessLogic.Marten/Immutable/Solution4/ShoppingCartValueObjects.cs b/Workshops/IntroductionToEventSourcing/Solved/08-BusinessLogic.Marten/Immutable/Solution4/ShoppingCartValueObjects.cs new file mode 100644 index 000000000..a63f0d649 --- /dev/null +++ b/Workshops/IntroductionToEventSourcing/Solved/08-BusinessLogic.Marten/Immutable/Solution4/ShoppingCartValueObjects.cs @@ -0,0 +1,64 @@ +namespace IntroductionToEventSourcing.BusinessLogic.Immutable.Solution4; + +public record ProductItem( + ProductId ProductId, + ProductQuantity Quantity +); + +public record PricedProductItem( + ProductId ProductId, + ProductQuantity Quantity, + ProductPrice UnitPrice +); + +public record ShoppingCartId(Guid Value) +{ + public static ShoppingCartId From(Guid? value) => + (value != null && value != Guid.Empty) + ? new ShoppingCartId(value.Value) + : throw new ArgumentOutOfRangeException(nameof(value)); +} + +public record ClientId(Guid Value) +{ + public static ClientId From(Guid? value) => + (value.HasValue && value != Guid.Empty) + ? new ClientId(value.Value) + : throw new ArgumentOutOfRangeException(nameof(value)); +} + +public record ProductId(Guid Value) +{ + public static ProductId From(Guid? value) => + (value.HasValue && value != Guid.Empty) + ? new ProductId(value.Value) + : throw new ArgumentOutOfRangeException(nameof(value)); +} + +public record ProductQuantity(int Value): + IComparable, + IComparable +{ + public static ProductQuantity From(int? value) => + value is > 0 + ? new ProductQuantity(value.Value) + : throw new ArgumentOutOfRangeException(nameof(value)); + + public int CompareTo(ProductQuantity? other) + { + if (ReferenceEquals(this, other)) return 0; + if (ReferenceEquals(null, other)) return 1; + return Value.CompareTo(other.Value); + } + + public int CompareTo(int other) => + Value.CompareTo(other); +} + +public record ProductPrice(decimal Value) +{ + public static ProductPrice From(decimal? value) => + value is > 0 + ? new ProductPrice(value.Value) + : throw new ArgumentOutOfRangeException(nameof(value)); +}