diff --git a/EventSourcing.NetCore.sln b/EventSourcing.NetCore.sln index 4f7fdf5f6..83dbce35f 100644 --- a/EventSourcing.NetCore.sln +++ b/EventSourcing.NetCore.sln @@ -467,6 +467,8 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "11-OptimisticConcurrency.Ev EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "07-BusinessLogic.Slimmed", "Workshops\IntroductionToEventSourcing\07-BusinessLogic.Slimmed\07-BusinessLogic.Slimmed.csproj", "{B7DBA2CE-DAA9-42C0-A967-E4B62297EC1D}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "07-BusinessLogic.Slimmed", "Workshops\IntroductionToEventSourcing\Solved\07-BusinessLogic.Slimmed\07-BusinessLogic.Slimmed.csproj", "{6E19670C-0723-4458-B4E8-01E5C51DAD2C}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -1049,6 +1051,10 @@ Global {B7DBA2CE-DAA9-42C0-A967-E4B62297EC1D}.Debug|Any CPU.Build.0 = Debug|Any CPU {B7DBA2CE-DAA9-42C0-A967-E4B62297EC1D}.Release|Any CPU.ActiveCfg = Release|Any CPU {B7DBA2CE-DAA9-42C0-A967-E4B62297EC1D}.Release|Any CPU.Build.0 = Release|Any CPU + {6E19670C-0723-4458-B4E8-01E5C51DAD2C}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {6E19670C-0723-4458-B4E8-01E5C51DAD2C}.Debug|Any CPU.Build.0 = Debug|Any CPU + {6E19670C-0723-4458-B4E8-01E5C51DAD2C}.Release|Any CPU.ActiveCfg = Release|Any CPU + {6E19670C-0723-4458-B4E8-01E5C51DAD2C}.Release|Any CPU.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE @@ -1241,6 +1247,7 @@ Global {7829C11A-59AF-4C10-A679-312B8940A68D} = {14C7B928-9D6C-441A-8A1F-0C49173E73EB} {37E05147-F579-458D-9B4F-25B54AC078DE} = {14C7B928-9D6C-441A-8A1F-0C49173E73EB} {B7DBA2CE-DAA9-42C0-A967-E4B62297EC1D} = {14C7B928-9D6C-441A-8A1F-0C49173E73EB} + {6E19670C-0723-4458-B4E8-01E5C51DAD2C} = {65F6E2BE-B2D4-4E56-B0CB-3062C4882B9E} EndGlobalSection GlobalSection(ExtensibilityGlobals) = postSolution SolutionGuid = {A5F55604-2FF3-43B7-B657-4F18E6E95D3B} diff --git a/Workshops/IntroductionToEventSourcing/Solved/07-BusinessLogic.Slimmed/07-BusinessLogic.Slimmed.csproj b/Workshops/IntroductionToEventSourcing/Solved/07-BusinessLogic.Slimmed/07-BusinessLogic.Slimmed.csproj new file mode 100644 index 000000000..96e907395 --- /dev/null +++ b/Workshops/IntroductionToEventSourcing/Solved/07-BusinessLogic.Slimmed/07-BusinessLogic.Slimmed.csproj @@ -0,0 +1,29 @@ + + + + net8.0 + IntroductionToEventSourcing.BusinessLogic.Slimmed + + + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + + all + runtime; build; native; contentfiles; analyzers + + + runtime; build; native; contentfiles; analyzers; buildtransitive + all + + + + + + diff --git a/Workshops/IntroductionToEventSourcing/Solved/07-BusinessLogic.Slimmed/Immutable/BusinessLogicTests.cs b/Workshops/IntroductionToEventSourcing/Solved/07-BusinessLogic.Slimmed/Immutable/BusinessLogicTests.cs new file mode 100644 index 000000000..cd7b25dee --- /dev/null +++ b/Workshops/IntroductionToEventSourcing/Solved/07-BusinessLogic.Slimmed/Immutable/BusinessLogicTests.cs @@ -0,0 +1,313 @@ +using Ogooreck.BusinessLogic; +using Xunit; + +namespace IntroductionToEventSourcing.BusinessLogic.Slimmed.Immutable; + +using static ShoppingCartEvent; +using static ShoppingCartCommand; +using static ShoppingCartService; + +public class BusinessLogicTests +{ + // Open + [Fact] + public void OpensShoppingCart() => + Spec.Given([]) + .When(() => Handle(new OpenShoppingCart(shoppingCartId, clientId, now))) + .Then(new ShoppingCartOpened(shoppingCartId, clientId, now)); + + // Add + [Fact] + public void CantAddProductItemToNotExistingShoppingCart() => + Spec.Given([]) + .When(state => + Handle( + FakeProductPriceCalculator.Returning(Price), + new AddProductItemToShoppingCart(shoppingCartId, ProductItem, now), + state + ) + ) + .ThenThrows(); + + [Fact] + public void AddsProductItemToEmptyShoppingCart() => + Spec.Given([ + new ShoppingCartOpened(shoppingCartId, clientId, now) + ]) + .When(state => + Handle( + FakeProductPriceCalculator.Returning(Price), + new AddProductItemToShoppingCart(shoppingCartId, ProductItem, now), + state + ) + ) + .Then( + new ProductItemAddedToShoppingCart( + shoppingCartId, + new PricedProductItem(ProductItem.ProductId, ProductItem.Quantity, Price), + now + ) + ); + + + [Fact] + public void AddsProductItemToNonEmptyShoppingCart() => + Spec.Given([ + new ShoppingCartOpened(shoppingCartId, clientId, now), + new ProductItemAddedToShoppingCart(shoppingCartId, pricedProductItem, now), + ]) + .When(state => + Handle( + FakeProductPriceCalculator.Returning(OtherPrice), + new AddProductItemToShoppingCart(shoppingCartId, OtherProductItem, now), + state + ) + ) + .Then( + new ProductItemAddedToShoppingCart( + shoppingCartId, + new PricedProductItem(OtherProductItem.ProductId, OtherProductItem.Quantity, OtherPrice), + now + ) + ); + + [Fact] + public void CantAddProductItemToConfirmedShoppingCart() => + Spec.Given([ + new ShoppingCartOpened(shoppingCartId, clientId, now), + new ProductItemAddedToShoppingCart(shoppingCartId, pricedProductItem, now), + new ShoppingCartConfirmed(shoppingCartId, now) + ]) + .When(state => + Handle( + FakeProductPriceCalculator.Returning(Price), + new AddProductItemToShoppingCart(shoppingCartId, ProductItem, now), + state + ) + ) + .ThenThrows(); + + [Fact] + public void CantAddProductItemToCanceledShoppingCart() => + Spec.Given([ + new ShoppingCartOpened(shoppingCartId, clientId, now), + new ProductItemAddedToShoppingCart(shoppingCartId, pricedProductItem, now), + new ShoppingCartCanceled(shoppingCartId, now) + ]) + .When(state => + Handle( + FakeProductPriceCalculator.Returning(Price), + new AddProductItemToShoppingCart(shoppingCartId, ProductItem, now), + state + ) + ) + .ThenThrows(); + + // Remove + [Fact] + public void CantRemoveProductItemFromNotExistingShoppingCart() => + Spec.Given([]) + .When(state => + Handle( + new RemoveProductItemFromShoppingCart(shoppingCartId, pricedProductItem, now), + state + ) + ) + .ThenThrows(); + + [Fact] + public void RemovesExistingProductItemFromShoppingCart() => + Spec.Given([ + new ShoppingCartOpened(shoppingCartId, clientId, now), + new ProductItemAddedToShoppingCart(shoppingCartId, pricedProductItem, now), + ]) + .When(state => + Handle( + new RemoveProductItemFromShoppingCart(shoppingCartId, pricedProductItem with { Quantity = 1 }, now), + state + ) + ) + .Then( + new ProductItemRemovedFromShoppingCart( + shoppingCartId, + pricedProductItem with { Quantity = 1 }, + now + ) + ); + + [Fact] + public void CantRemoveNonExistingProductItemFromNonEmptyShoppingCart() => + Spec.Given([ + new ShoppingCartOpened(shoppingCartId, clientId, now), + new ProductItemAddedToShoppingCart(shoppingCartId, pricedProductItem, now), + ]) + .When(state => + Handle( + new RemoveProductItemFromShoppingCart(shoppingCartId, otherPricedProductItem with { Quantity = 1 }, + now), + state + ) + ) + .ThenThrows(); + + [Fact] + public void CantRemoveExistingProductItemFromCanceledShoppingCart() => + Spec.Given([ + new ShoppingCartOpened(shoppingCartId, clientId, now), + new ProductItemAddedToShoppingCart(shoppingCartId, pricedProductItem, now), + new ShoppingCartConfirmed(shoppingCartId, now) + ]) + .When(state => + Handle( + new RemoveProductItemFromShoppingCart(shoppingCartId, pricedProductItem with { Quantity = 1 }, now), + state + ) + ) + .ThenThrows(); + + [Fact] + public void CantRemoveExistingProductItemFromConfirmedShoppingCart() => + Spec.Given([ + new ShoppingCartOpened(shoppingCartId, clientId, now), + new ProductItemAddedToShoppingCart(shoppingCartId, pricedProductItem, now), + new ShoppingCartCanceled(shoppingCartId, now) + ]) + .When(state => + Handle( + new RemoveProductItemFromShoppingCart(shoppingCartId, pricedProductItem with { Quantity = 1 }, now), + state + ) + ) + .ThenThrows(); + + // Confirm + + [Fact] + public void CantConfirmNotExistingShoppingCart() => + Spec.Given([]) + .When(state => + Handle(new ConfirmShoppingCart(shoppingCartId, now), state) + ) + .ThenThrows(); + + [Fact] + [Trait("Category", "SkipCI")] + public void CantConfirmEmptyShoppingCart() => + Spec.Given([ + new ShoppingCartOpened(shoppingCartId, clientId, now), + ]) + .When(state => + Handle(new ConfirmShoppingCart(shoppingCartId, now), state) + ) + .ThenThrows(); + + [Fact] + public void ConfirmsNonEmptyShoppingCart() => + Spec.Given([ + new ShoppingCartOpened(shoppingCartId, clientId, now), + new ProductItemAddedToShoppingCart(shoppingCartId, pricedProductItem, now), + ]) + .When(state => + Handle(new ConfirmShoppingCart(shoppingCartId, now), state) + ) + .Then( + new ShoppingCartConfirmed(shoppingCartId, now) + ); + + [Fact] + public void CantConfirmAlreadyConfirmedShoppingCart() => + Spec.Given([ + new ShoppingCartOpened(shoppingCartId, clientId, now), + new ProductItemAddedToShoppingCart(shoppingCartId, pricedProductItem, now), + new ShoppingCartConfirmed(shoppingCartId, now) + ]) + .When(state => + Handle(new ConfirmShoppingCart(shoppingCartId, now), state) + ) + .ThenThrows(); + + [Fact] + public void CantConfirmCanceledShoppingCart() => + Spec.Given([ + new ShoppingCartOpened(shoppingCartId, clientId, now), + new ProductItemAddedToShoppingCart(shoppingCartId, pricedProductItem, now), + new ShoppingCartCanceled(shoppingCartId, now) + ]) + .When(state => + Handle(new ConfirmShoppingCart(shoppingCartId, now), state) + ) + .ThenThrows(); + + // Cancel + [Fact] + [Trait("Category", "SkipCI")] + public void CantCancelNotExistingShoppingCart() => + Spec.Given([]) + .When(state => + Handle(new CancelShoppingCart(shoppingCartId, now), state) + ) + .ThenThrows(); + + [Fact] + public void CancelsEmptyShoppingCart() => + Spec.Given([ + new ShoppingCartOpened(shoppingCartId, clientId, now), + ]) + .When(state => + Handle(new CancelShoppingCart(shoppingCartId, now), state) + ) + .ThenThrows(); + + [Fact] + public void CancelsNonEmptyShoppingCart() => + Spec.Given([ + new ShoppingCartOpened(shoppingCartId, clientId, now), + new ProductItemAddedToShoppingCart(shoppingCartId, pricedProductItem, now), + ]) + .When(state => + Handle(new CancelShoppingCart(shoppingCartId, now), state) + ) + .Then( + new ShoppingCartCanceled(shoppingCartId, now) + ); + + [Fact] + public void CantCancelAlreadyCanceledShoppingCart() => + Spec.Given([ + new ShoppingCartOpened(shoppingCartId, clientId, now), + new ProductItemAddedToShoppingCart(shoppingCartId, pricedProductItem, now), + new ShoppingCartCanceled(shoppingCartId, now) + ]) + .When(state => + Handle(new CancelShoppingCart(shoppingCartId, now), state) + ) + .ThenThrows(); + + [Fact] + public void CantCancelConfirmedShoppingCart() => + Spec.Given([ + new ShoppingCartOpened(shoppingCartId, clientId, now), + new ProductItemAddedToShoppingCart(shoppingCartId, pricedProductItem, now), + new ShoppingCartConfirmed(shoppingCartId, now) + ]) + .When(state => + Handle(new CancelShoppingCart(shoppingCartId, now), state) + ) + .ThenThrows(); + + private readonly DateTimeOffset now = DateTimeOffset.Now; + private readonly Guid clientId = Guid.NewGuid(); + private readonly Guid shoppingCartId = Guid.NewGuid(); + private static readonly ProductItem ProductItem = new(Guid.NewGuid(), Random.Shared.Next(1, 200)); + private static readonly ProductItem OtherProductItem = new(Guid.NewGuid(), Random.Shared.Next(1, 200)); + private static readonly int Price = Random.Shared.Next(1, 1000); + private static readonly int OtherPrice = Random.Shared.Next(1, 1000); + private readonly PricedProductItem pricedProductItem = + new(ProductItem.ProductId, ProductItem.Quantity, Price); + private readonly PricedProductItem otherPricedProductItem = + new(OtherProductItem.ProductId, OtherProductItem.Quantity, Price); + + + private readonly HandlerSpecification Spec = + Specification.For(ShoppingCart.Evolve, ShoppingCart.Initial); +} diff --git a/Workshops/IntroductionToEventSourcing/Solved/07-BusinessLogic.Slimmed/Immutable/PricingCalculator.cs b/Workshops/IntroductionToEventSourcing/Solved/07-BusinessLogic.Slimmed/Immutable/PricingCalculator.cs new file mode 100644 index 000000000..013d92548 --- /dev/null +++ b/Workshops/IntroductionToEventSourcing/Solved/07-BusinessLogic.Slimmed/Immutable/PricingCalculator.cs @@ -0,0 +1,24 @@ +namespace IntroductionToEventSourcing.BusinessLogic.Slimmed.Immutable; + +public interface IProductPriceCalculator +{ + PricedProductItem Calculate(ProductItem productItems); +} + +public class FakeProductPriceCalculator: IProductPriceCalculator +{ + private readonly int value; + + private FakeProductPriceCalculator(int value) + { + this.value = value; + } + + public static FakeProductPriceCalculator Returning(int value) => new(value); + + public PricedProductItem Calculate(ProductItem productItem) + { + var (productId, quantity) = productItem; + return new PricedProductItem(productId, quantity, value); + } +} diff --git a/Workshops/IntroductionToEventSourcing/Solved/07-BusinessLogic.Slimmed/Immutable/ShoppingCart.cs b/Workshops/IntroductionToEventSourcing/Solved/07-BusinessLogic.Slimmed/Immutable/ShoppingCart.cs new file mode 100644 index 000000000..96951b426 --- /dev/null +++ b/Workshops/IntroductionToEventSourcing/Solved/07-BusinessLogic.Slimmed/Immutable/ShoppingCart.cs @@ -0,0 +1,142 @@ +namespace IntroductionToEventSourcing.BusinessLogic.Slimmed.Immutable; +using static ShoppingCartEvent; + +// EVENTS +public abstract record ShoppingCartEvent +{ + public record ShoppingCartOpened( + Guid ShoppingCartId, + Guid ClientId, + DateTimeOffset OpenedAt + ): ShoppingCartEvent; + + public record ProductItemAddedToShoppingCart( + Guid ShoppingCartId, + PricedProductItem ProductItem, + DateTimeOffset AddedAt + ): ShoppingCartEvent; + + public record ProductItemRemovedFromShoppingCart( + Guid ShoppingCartId, + PricedProductItem ProductItem, + DateTimeOffset RemovedAt + ): ShoppingCartEvent; + + public record ShoppingCartConfirmed( + Guid ShoppingCartId, + DateTimeOffset ConfirmedAt + ): ShoppingCartEvent; + + public record ShoppingCartCanceled( + Guid ShoppingCartId, + DateTimeOffset CanceledAt + ): ShoppingCartEvent; + + // This won't allow external inheritance + private ShoppingCartEvent(){} +} + +// VALUE OBJECTS +public record PricedProductItem( + Guid ProductId, + int Quantity, + decimal UnitPrice +) +{ + public decimal TotalPrice => Quantity * UnitPrice; +} + +public record ProductItem(Guid ProductId, int Quantity); + + +// ENTITY +public record ShoppingCart( + Guid Id, + Guid ClientId, + ShoppingCartStatus Status, + PricedProductItem[] ProductItems, + DateTimeOffset OpenedAt, + DateTimeOffset? ConfirmedAt = null, + DateTimeOffset? CanceledAt = null +) +{ + public bool IsClosed => ShoppingCartStatus.Closed.HasFlag(Status); + + public bool HasEnough(PricedProductItem productItem) + { + var (productId, quantity, _) = productItem; + var currentQuantity = ProductItems.Where(pi => pi.ProductId == productId) + .Select(pi => pi.Quantity) + .FirstOrDefault(); + + return currentQuantity >= quantity; + } + + public static ShoppingCart Initial() => + new (default, default, default, [], default); + + public static ShoppingCart Evolve(ShoppingCart shoppingCart, ShoppingCartEvent @event) + { + return @event switch + { + ShoppingCartOpened(var shoppingCartId, var clientId, var openedAt) => + shoppingCart with + { + Id = shoppingCartId, + ClientId = clientId, + Status = ShoppingCartStatus.Pending, + ProductItems = [], + OpenedAt = openedAt + }, + ProductItemAddedToShoppingCart(_, var pricedProductItem, _) => + shoppingCart with + { + ProductItems = shoppingCart.ProductItems + .Concat(new [] { pricedProductItem }) + .GroupBy(pi => pi.ProductId) + .Select(group => group.Count() == 1? + group.First() + : new PricedProductItem( + group.Key, + group.Sum(pi => pi.Quantity), + group.First().UnitPrice + ) + ) + .ToArray() + }, + ProductItemRemovedFromShoppingCart(_, var pricedProductItem, _) => + shoppingCart with + { + ProductItems = shoppingCart.ProductItems + .Select(pi => pi.ProductId == pricedProductItem.ProductId? + pi with { Quantity = pi.Quantity - pricedProductItem.Quantity } + :pi + ) + .Where(pi => pi.Quantity > 0) + .ToArray() + }, + ShoppingCartConfirmed(_, var confirmedAt) => + shoppingCart with + { + Status = ShoppingCartStatus.Confirmed, + ConfirmedAt = confirmedAt + }, + ShoppingCartCanceled(_, var canceledAt) => + shoppingCart with + { + Status = ShoppingCartStatus.Canceled, + CanceledAt = canceledAt + }, + _ => shoppingCart + }; + } +} + +public enum ShoppingCartStatus +{ + Pending = 1, + Confirmed = 2, + Canceled = 4, + + Closed = Confirmed | Canceled +} diff --git a/Workshops/IntroductionToEventSourcing/Solved/07-BusinessLogic.Slimmed/Immutable/ShoppingCartService.cs b/Workshops/IntroductionToEventSourcing/Solved/07-BusinessLogic.Slimmed/Immutable/ShoppingCartService.cs new file mode 100644 index 000000000..8ad2ecb64 --- /dev/null +++ b/Workshops/IntroductionToEventSourcing/Solved/07-BusinessLogic.Slimmed/Immutable/ShoppingCartService.cs @@ -0,0 +1,117 @@ +namespace IntroductionToEventSourcing.BusinessLogic.Slimmed.Immutable; +using static ShoppingCartEvent; +using static ShoppingCartCommand; + +public abstract record ShoppingCartCommand +{ + public record OpenShoppingCart( + Guid ShoppingCartId, + Guid ClientId, + DateTimeOffset Now + ): ShoppingCartCommand; + + public record AddProductItemToShoppingCart( + Guid ShoppingCartId, + ProductItem ProductItem, + DateTimeOffset Now + ); + + public record RemoveProductItemFromShoppingCart( + Guid ShoppingCartId, + PricedProductItem ProductItem, + DateTimeOffset Now + ); + + public record ConfirmShoppingCart( + Guid ShoppingCartId, + DateTimeOffset Now + ); + + public record CancelShoppingCart( + Guid ShoppingCartId, + DateTimeOffset Now + ): ShoppingCartCommand; + + private ShoppingCartCommand() {} +} + +public static class ShoppingCartService +{ + public static ShoppingCartOpened Handle(OpenShoppingCart command) + { + var (shoppingCartId, clientId, now) = command; + + return new ShoppingCartOpened( + shoppingCartId, + clientId, + now + ); + } + + public static ProductItemAddedToShoppingCart Handle( + IProductPriceCalculator priceCalculator, + AddProductItemToShoppingCart command, + ShoppingCart shoppingCart + ) + { + var (cartId, productItem, now) = command; + + if (shoppingCart.IsClosed) + throw new InvalidOperationException( + $"Adding product item for cart in '{shoppingCart.Status}' status is not allowed."); + + var pricedProductItem = priceCalculator.Calculate(productItem); + + return new ProductItemAddedToShoppingCart( + cartId, + pricedProductItem, + now + ); + } + + public static ProductItemRemovedFromShoppingCart Handle( + RemoveProductItemFromShoppingCart command, + ShoppingCart shoppingCart + ) + { + var (cartId, productItem, now) = command; + + if (shoppingCart.IsClosed) + throw new InvalidOperationException( + $"Adding product item for cart in '{shoppingCart.Status}' status is not allowed."); + + if (!shoppingCart.HasEnough(productItem)) + throw new InvalidOperationException("Not enough product items to remove"); + + return new ProductItemRemovedFromShoppingCart( + cartId, + productItem, + now + ); + } + + public static ShoppingCartConfirmed Handle(ConfirmShoppingCart command, ShoppingCart shoppingCart) + { + if (shoppingCart.IsClosed) + throw new InvalidOperationException($"Confirming cart in '{shoppingCart.Status}' status is not allowed."); + + if(shoppingCart.ProductItems.Length == 0) + throw new InvalidOperationException("Cannot confirm empty shopping cart"); + + return new ShoppingCartConfirmed( + shoppingCart.Id, + command.Now + ); + } + + public static ShoppingCartCanceled Handle(CancelShoppingCart command, ShoppingCart shoppingCart) + { + if (shoppingCart.IsClosed) + throw new InvalidOperationException($"Canceling cart in '{shoppingCart.Status}' status is not allowed."); + + return new ShoppingCartCanceled( + shoppingCart.Id, + command.Now + ); + } +} diff --git a/Workshops/IntroductionToEventSourcing/Solved/07-BusinessLogic.Slimmed/Mixed/Aggregate.cs b/Workshops/IntroductionToEventSourcing/Solved/07-BusinessLogic.Slimmed/Mixed/Aggregate.cs new file mode 100644 index 000000000..8934d35ed --- /dev/null +++ b/Workshops/IntroductionToEventSourcing/Solved/07-BusinessLogic.Slimmed/Mixed/Aggregate.cs @@ -0,0 +1,8 @@ +namespace IntroductionToEventSourcing.BusinessLogic.Slimmed.Mixed; + +public interface IAggregate +{ + Guid Id { get; } + + void Evolve(object @event) { } +} diff --git a/Workshops/IntroductionToEventSourcing/Solved/07-BusinessLogic.Slimmed/Mixed/BusinessLogicTests.cs b/Workshops/IntroductionToEventSourcing/Solved/07-BusinessLogic.Slimmed/Mixed/BusinessLogicTests.cs new file mode 100644 index 000000000..6c85f004e --- /dev/null +++ b/Workshops/IntroductionToEventSourcing/Solved/07-BusinessLogic.Slimmed/Mixed/BusinessLogicTests.cs @@ -0,0 +1,324 @@ +using Ogooreck.BusinessLogic; +using Xunit; + +namespace IntroductionToEventSourcing.BusinessLogic.Slimmed.Mixed; + +using static ShoppingCartEvent; + +public class BusinessLogicTests +{ + // Open + [Fact] + public void OpensShoppingCart() => + Spec.Given([]) + .When(() => ShoppingCart.Open(shoppingCartId, clientId, now).Item1) + .Then(new ShoppingCartOpened(shoppingCartId, clientId, now)); + + // Add + [Fact] + public void CantAddProductItemToNotExistingShoppingCart() => + Spec.Given([]) + .When(state => + state.AddProduct( + FakeProductPriceCalculator.Returning(Price), + ProductItem(), + now + ) + ) + .ThenThrows(); + + [Fact] + public void AddsProductItemToEmptyShoppingCart() => + Spec.Given([ + new ShoppingCartOpened(shoppingCartId, clientId, now) + ]) + .When(state => + state.AddProduct( + FakeProductPriceCalculator.Returning(Price), + ProductItem(), + now + ) + ) + .Then( + new ProductItemAddedToShoppingCart( + shoppingCartId, + new PricedProductItem + { + ProductId = ProductItem().ProductId, Quantity = ProductItem().Quantity, UnitPrice = Price + }, + now + ) + ); + + + [Fact] + public void AddsProductItemToNonEmptyShoppingCart() => + Spec.Given([ + new ShoppingCartOpened(shoppingCartId, clientId, now), + new ProductItemAddedToShoppingCart(shoppingCartId, PricedProductItem(), now), + ]) + .When(state => + state.AddProduct( + FakeProductPriceCalculator.Returning(OtherPrice), + OtherProductItem(), + now + ) + ) + .Then( + new ProductItemAddedToShoppingCart( + shoppingCartId, + new PricedProductItem + { + ProductId = OtherProductItem().ProductId, + Quantity = OtherProductItem().Quantity, + UnitPrice = OtherPrice + }, + now + ) + ); + + [Fact] + public void CantAddProductItemToConfirmedShoppingCart() => + Spec.Given([ + new ShoppingCartOpened(shoppingCartId, clientId, now), + new ProductItemAddedToShoppingCart(shoppingCartId, PricedProductItem(), now), + new ShoppingCartConfirmed(shoppingCartId, now) + ]) + .When(state => + state.AddProduct( + FakeProductPriceCalculator.Returning(Price), + ProductItem(), + now + ) + ) + .ThenThrows(); + + [Fact] + public void CantAddProductItemToCanceledShoppingCart() => + Spec.Given([ + new ShoppingCartOpened(shoppingCartId, clientId, now), + new ProductItemAddedToShoppingCart(shoppingCartId, PricedProductItem(), now), + new ShoppingCartCanceled(shoppingCartId, now) + ]) + .When(state => + state.AddProduct( + FakeProductPriceCalculator.Returning(Price), + ProductItem(), + now + ) + ) + .ThenThrows(); + + // Remove + [Fact] + public void CantRemoveProductItemFromNotExistingShoppingCart() => + Spec.Given([]) + .When(state => + state.RemoveProduct(PricedProductItem(), now) + ) + .ThenThrows(); + + [Fact] + public void RemovesExistingProductItemFromShoppingCart() => + Spec.Given([ + new ShoppingCartOpened(shoppingCartId, clientId, now), + new ProductItemAddedToShoppingCart(shoppingCartId, PricedProductItem(), now), + ]) + .When(state => + state.RemoveProduct(PricedProductItemWithQuantity(1), now) + ) + .Then( + new ProductItemRemovedFromShoppingCart( + shoppingCartId, + PricedProductItemWithQuantity(1), + now + ) + ); + + [Fact] + public void CantRemoveNonExistingProductItemFromNonEmptyShoppingCart() => + Spec.Given([ + new ShoppingCartOpened(shoppingCartId, clientId, now), + new ProductItemAddedToShoppingCart(shoppingCartId, OtherPricedProductItem(), now), + ]) + .When(state => + state.RemoveProduct(PricedProductItemWithQuantity(1), now) + ) + .ThenThrows(); + + [Fact] + public void CantRemoveExistingProductItemFromCanceledShoppingCart() => + Spec.Given([ + new ShoppingCartOpened(shoppingCartId, clientId, now), + new ProductItemAddedToShoppingCart(shoppingCartId, PricedProductItem(), now), + new ShoppingCartConfirmed(shoppingCartId, now) + ]) + .When(state => + state.RemoveProduct(PricedProductItemWithQuantity(1), now) + ) + .ThenThrows(); + + [Fact] + public void CantRemoveExistingProductItemFromConfirmedShoppingCart() => + Spec.Given([ + new ShoppingCartOpened(shoppingCartId, clientId, now), + new ProductItemAddedToShoppingCart(shoppingCartId, PricedProductItem(), now), + new ShoppingCartCanceled(shoppingCartId, now) + ]) + .When(state => + state.RemoveProduct(PricedProductItemWithQuantity(1), now) + ) + .ThenThrows(); + + // Confirm + [Fact] + public void CantConfirmNotExistingShoppingCart() => + Spec.Given([]) + .When(state => + state.Confirm(now) + ) + .ThenThrows(); + + [Fact] + [Trait("Category", "SkipCI")] + public void CantConfirmEmptyShoppingCart() => + Spec.Given([ + new ShoppingCartOpened(shoppingCartId, clientId, now), + ]) + .When(state => + state.Confirm(now) + ) + .ThenThrows(); + + [Fact] + public void ConfirmsNonEmptyShoppingCart() => + Spec.Given([ + new ShoppingCartOpened(shoppingCartId, clientId, now), + new ProductItemAddedToShoppingCart(shoppingCartId, PricedProductItem(), now), + ]) + .When(state => + state.Confirm(now) + ) + .Then( + new ShoppingCartConfirmed(shoppingCartId, now) + ); + + [Fact] + public void CantConfirmAlreadyConfirmedShoppingCart() => + Spec.Given([ + new ShoppingCartOpened(shoppingCartId, clientId, now), + new ProductItemAddedToShoppingCart(shoppingCartId, PricedProductItem(), now), + new ShoppingCartConfirmed(shoppingCartId, now) + ]) + .When(state => + state.Confirm(now) + ) + .ThenThrows(); + + [Fact] + public void CantConfirmCanceledShoppingCart() => + Spec.Given([ + new ShoppingCartOpened(shoppingCartId, clientId, now), + new ProductItemAddedToShoppingCart(shoppingCartId, PricedProductItem(), now), + new ShoppingCartCanceled(shoppingCartId, now) + ]) + .When(state => + state.Confirm(now) + ) + .ThenThrows(); + + // Cancel + [Fact] + [Trait("Category", "SkipCI")] + public void CantCancelNotExistingShoppingCart() => + Spec.Given([]) + .When(state => + state.Cancel(now) + ) + .ThenThrows(); + + [Fact] + public void CancelsEmptyShoppingCart() => + Spec.Given([ + new ShoppingCartOpened(shoppingCartId, clientId, now), + ]) + .When(state => + state.Cancel(now) + ) + .ThenThrows(); + + [Fact] + public void CancelsNonEmptyShoppingCart() => + Spec.Given([ + new ShoppingCartOpened(shoppingCartId, clientId, now), + new ProductItemAddedToShoppingCart(shoppingCartId, PricedProductItem(), now), + ]) + .When(state => + state.Cancel(now) + ) + .Then( + new ShoppingCartCanceled(shoppingCartId, now) + ); + + [Fact] + public void CantCancelAlreadyCanceledShoppingCart() => + Spec.Given([ + new ShoppingCartOpened(shoppingCartId, clientId, now), + new ProductItemAddedToShoppingCart(shoppingCartId, PricedProductItem(), now), + new ShoppingCartCanceled(shoppingCartId, now) + ]) + .When(state => + state.Cancel(now) + ) + .ThenThrows(); + + [Fact] + public void CantCancelConfirmedShoppingCart() => + Spec.Given([ + new ShoppingCartOpened(shoppingCartId, clientId, now), + new ProductItemAddedToShoppingCart(shoppingCartId, PricedProductItem(), now), + new ShoppingCartConfirmed(shoppingCartId, now) + ]) + .When(state => + state.Cancel(now) + ) + .ThenThrows(); + + private readonly DateTimeOffset now = DateTimeOffset.Now; + private readonly Guid clientId = Guid.NewGuid(); + private readonly Guid shoppingCartId = Guid.NewGuid(); + private static readonly Guid ProductId = Guid.NewGuid(); + private static readonly Guid OtherProductId = Guid.NewGuid(); + private static readonly int Quantity = Random.Shared.Next(1, 1000); + private static readonly int OtherQuantity = Random.Shared.Next(1, 1000); + private static readonly int Price = Random.Shared.Next(1, 1000); + private static readonly int OtherPrice = Random.Shared.Next(1, 1000); + + private static readonly Func ProductItem = () => + new ProductItem { ProductId = ProductId, Quantity = Quantity }; + + private static readonly Func OtherProductItem = () => + new ProductItem { ProductId = OtherProductId, Quantity = OtherQuantity }; + + private static readonly Func PricedProductItem = () => new PricedProductItem + { + ProductId = ProductId, Quantity = Quantity, UnitPrice = Price + }; + + private static readonly Func PricedProductItemWithQuantity = + quantity => new PricedProductItem { ProductId = ProductId, Quantity = quantity, UnitPrice = Price }; + + private static readonly Func OtherPricedProductItem = () => new PricedProductItem + { + ProductId = OtherProductId, Quantity = OtherQuantity, UnitPrice = Price + }; + + private readonly HandlerSpecification Spec = + Specification.For(Evolve, ShoppingCart.Initial); + + private static readonly Func Evolve = (cart, @event) => + { + cart.Evolve(@event); + return cart; + }; +} diff --git a/Workshops/IntroductionToEventSourcing/Solved/07-BusinessLogic.Slimmed/Mixed/PricingCalculator.cs b/Workshops/IntroductionToEventSourcing/Solved/07-BusinessLogic.Slimmed/Mixed/PricingCalculator.cs new file mode 100644 index 000000000..2ef3e96a5 --- /dev/null +++ b/Workshops/IntroductionToEventSourcing/Solved/07-BusinessLogic.Slimmed/Mixed/PricingCalculator.cs @@ -0,0 +1,21 @@ +namespace IntroductionToEventSourcing.BusinessLogic.Slimmed.Mixed; + +public interface IProductPriceCalculator +{ + PricedProductItem Calculate(ProductItem productItems); +} + +public class FakeProductPriceCalculator: IProductPriceCalculator +{ + private readonly int value; + + private FakeProductPriceCalculator(int value) + { + this.value = value; + } + + public static FakeProductPriceCalculator Returning(int value) => new(value); + + public PricedProductItem Calculate(ProductItem productItem) => + new() { ProductId = productItem.ProductId, Quantity = productItem.Quantity, UnitPrice = value }; +} diff --git a/Workshops/IntroductionToEventSourcing/Solved/07-BusinessLogic.Slimmed/Mixed/ShoppingCart.cs b/Workshops/IntroductionToEventSourcing/Solved/07-BusinessLogic.Slimmed/Mixed/ShoppingCart.cs new file mode 100644 index 000000000..45be6aa9e --- /dev/null +++ b/Workshops/IntroductionToEventSourcing/Solved/07-BusinessLogic.Slimmed/Mixed/ShoppingCart.cs @@ -0,0 +1,250 @@ +namespace IntroductionToEventSourcing.BusinessLogic.Slimmed.Mixed; + +using static ShoppingCartEvent; + +// EVENTS +public abstract record ShoppingCartEvent +{ + public record ShoppingCartOpened( + Guid ShoppingCartId, + Guid ClientId, + DateTimeOffset OpenedAt + ): ShoppingCartEvent; + + public record ProductItemAddedToShoppingCart( + Guid ShoppingCartId, + PricedProductItem ProductItem, + DateTimeOffset AddedAt + ): ShoppingCartEvent; + + public record ProductItemRemovedFromShoppingCart( + Guid ShoppingCartId, + PricedProductItem ProductItem, + DateTimeOffset RemovedAt + ): ShoppingCartEvent; + + public record ShoppingCartConfirmed( + Guid ShoppingCartId, + DateTimeOffset ConfirmedAt + ): ShoppingCartEvent; + + public record ShoppingCartCanceled( + Guid ShoppingCartId, + DateTimeOffset CanceledAt + ): ShoppingCartEvent; + + // This won't allow external inheritance + private ShoppingCartEvent() { } +} + +// VALUE OBJECTS +public class PricedProductItem +{ + public Guid ProductId { get; set; } + public decimal UnitPrice { get; set; } + public int Quantity { get; set; } + public decimal TotalPrice => Quantity * UnitPrice; +} + +public class ProductItem +{ + public Guid ProductId { get; set; } + public int Quantity { get; set; } +} + +// ENTITY +public class ShoppingCart: IAggregate +{ + public Guid Id { get; private set; } + public Guid ClientId { get; private set; } + public ShoppingCartStatus Status { get; private set; } + public IList ProductItems { get; } = new List(); + public DateTimeOffset OpenedAt { get; private set; } + public DateTimeOffset? ConfirmedAt { get; private set; } + public DateTimeOffset? CanceledAt { get; private set; } + + public bool IsClosed => ShoppingCartStatus.Closed.HasFlag(Status); + + public void Evolve(object @event) + { + switch (@event) + { + case ShoppingCartOpened opened: + Apply(opened); + break; + case ProductItemAddedToShoppingCart productItemAdded: + Apply(productItemAdded); + break; + case ProductItemRemovedFromShoppingCart productItemRemoved: + Apply(productItemRemoved); + break; + case ShoppingCartConfirmed confirmed: + Apply(confirmed); + break; + case ShoppingCartCanceled canceled: + Apply(canceled); + break; + } + } + + public static (ShoppingCartOpened, ShoppingCart) Open( + Guid cartId, + Guid clientId, + DateTimeOffset now + ) + { + var @event = new ShoppingCartOpened(cartId, clientId, now); + + return (@event, new ShoppingCart(@event)); + } + + public static ShoppingCart Initial() => new(); + + private ShoppingCart(ShoppingCartOpened @event) => + Apply(@event); + + //just for default creation of empty object + private ShoppingCart() { } + + private void Apply(ShoppingCartOpened opened) + { + Id = opened.ShoppingCartId; + ClientId = opened.ClientId; + Status = ShoppingCartStatus.Pending; + } + + public ProductItemAddedToShoppingCart AddProduct( + IProductPriceCalculator productPriceCalculator, + ProductItem productItem, + DateTimeOffset now + ) + { + if (IsClosed) + throw new InvalidOperationException( + $"Adding product item for cart in '{Status}' status is not allowed."); + + var pricedProductItem = productPriceCalculator.Calculate(productItem); + + var @event = new ProductItemAddedToShoppingCart(Id, pricedProductItem, now); + + Apply(@event); + + return @event; + } + + private void Apply(ProductItemAddedToShoppingCart productItemAdded) + { + var pricedProductItem = productItemAdded.ProductItem; + var productId = pricedProductItem.ProductId; + var quantityToAdd = pricedProductItem.Quantity; + + var current = ProductItems.SingleOrDefault( + pi => pi.ProductId == productId + ); + + if (current == null) + ProductItems.Add(pricedProductItem); + else + ProductItems[ProductItems.IndexOf(current)] = new PricedProductItem + { + Quantity = current.Quantity + quantityToAdd, ProductId = productId, UnitPrice = current.UnitPrice + }; + } + + public ProductItemRemovedFromShoppingCart RemoveProduct( + PricedProductItem productItemToBeRemoved, + DateTimeOffset now + ) + { + if (IsClosed) + throw new InvalidOperationException( + $"Removing product item for cart in '{Status}' status is not allowed."); + + if (!HasEnough(productItemToBeRemoved)) + throw new InvalidOperationException("Not enough product items to remove"); + + var @event = new ProductItemRemovedFromShoppingCart(Id, productItemToBeRemoved, now); + + Apply(@event); + + return @event; + } + + private bool HasEnough(PricedProductItem productItem) + { + var currentQuantity = ProductItems.Where(pi => pi.ProductId == productItem.ProductId) + .Select(pi => pi.Quantity) + .FirstOrDefault(); + + return currentQuantity >= productItem.Quantity; + } + + private void Apply(ProductItemRemovedFromShoppingCart productItemRemoved) + { + var pricedProductItem = productItemRemoved.ProductItem; + var productId = pricedProductItem.ProductId; + var quantityToRemove = pricedProductItem.Quantity; + + var current = ProductItems.Single( + pi => pi.ProductId == productId + ); + + if (current.Quantity == quantityToRemove) + ProductItems.Remove(current); + else + ProductItems[ProductItems.IndexOf(current)] = new PricedProductItem + { + Quantity = current.Quantity - quantityToRemove, ProductId = productId, UnitPrice = current.UnitPrice + }; + } + + public ShoppingCartConfirmed Confirm(DateTimeOffset now) + { + if (IsClosed) + throw new InvalidOperationException( + $"Confirming cart in '{Status}' status is not allowed."); + + if (ProductItems.Count == 0) + throw new InvalidOperationException("Cannot confirm empty shopping cart"); + + var @event = new ShoppingCartConfirmed(Id, now); + + Apply(@event); + + return @event; + } + + private void Apply(ShoppingCartConfirmed confirmed) + { + Status = ShoppingCartStatus.Confirmed; + ConfirmedAt = confirmed.ConfirmedAt; + } + + public ShoppingCartCanceled Cancel(DateTimeOffset now) + { + if (IsClosed) + throw new InvalidOperationException( + $"Canceling cart in '{Status}' status is not allowed."); + + var @event = new ShoppingCartCanceled(Id, now); + + Apply(@event); + + return @event; + } + + private void Apply(ShoppingCartCanceled canceled) + { + Status = ShoppingCartStatus.Canceled; + CanceledAt = canceled.CanceledAt; + } +} + +public enum ShoppingCartStatus +{ + Pending = 1, + Confirmed = 2, + Canceled = 4, + + Closed = Confirmed | Canceled +} diff --git a/Workshops/IntroductionToEventSourcing/Solved/07-BusinessLogic.Slimmed/Mutable/Aggregate.cs b/Workshops/IntroductionToEventSourcing/Solved/07-BusinessLogic.Slimmed/Mutable/Aggregate.cs new file mode 100644 index 000000000..5f9c66d69 --- /dev/null +++ b/Workshops/IntroductionToEventSourcing/Solved/07-BusinessLogic.Slimmed/Mutable/Aggregate.cs @@ -0,0 +1,43 @@ +namespace IntroductionToEventSourcing.BusinessLogic.Slimmed.Mutable; + +public interface IAggregate +{ + public void Evolve(object @event); + + object[] DequeueUncommittedEvents(); +} + +public abstract class Aggregate: IAggregate +{ + public Guid Id { get; protected set; } + + private readonly Queue uncommittedEvents = new(); + + public virtual void Evolve(TEvent @event) { } + + void IAggregate.Evolve(object @event) + { + if (@event is not TEvent typed) + return; + + Evolve(typed); + } + + object[] IAggregate.DequeueUncommittedEvents() => + DequeueUncommittedEvents().Cast().ToArray(); + + public TEvent[] DequeueUncommittedEvents() + { + var dequeuedEvents = uncommittedEvents.ToArray(); + + uncommittedEvents.Clear(); + + return dequeuedEvents; + } + + protected void Enqueue(TEvent @event) + { + uncommittedEvents.Enqueue(@event); + Evolve(@event); + } +} diff --git a/Workshops/IntroductionToEventSourcing/Solved/07-BusinessLogic.Slimmed/Mutable/BusinessLogicTests.cs b/Workshops/IntroductionToEventSourcing/Solved/07-BusinessLogic.Slimmed/Mutable/BusinessLogicTests.cs new file mode 100644 index 000000000..1a68b8b16 --- /dev/null +++ b/Workshops/IntroductionToEventSourcing/Solved/07-BusinessLogic.Slimmed/Mutable/BusinessLogicTests.cs @@ -0,0 +1,351 @@ +using Ogooreck.BusinessLogic; +using Xunit; + +namespace IntroductionToEventSourcing.BusinessLogic.Slimmed.Mutable; + +using static ShoppingCartEvent; + +public class BusinessLogicTests +{ + // Open + [Fact] + public void OpensShoppingCart() => + Spec.Given([]) + .When(() => + ShoppingCart.Open(shoppingCartId, clientId, now) + ) + .Then(new ShoppingCartOpened(shoppingCartId, clientId, now)); + + // Add + [Fact] + public void CantAddProductItemToNotExistingShoppingCart() => + Spec.Given([]) + .When(state => + state.AddProduct( + FakeProductPriceCalculator.Returning(Price), + ProductItem(), + now + ) + ) + .ThenThrows(); + + [Fact] + public void AddsProductItemToEmptyShoppingCart() => + Spec.Given([ + new ShoppingCartOpened(shoppingCartId, clientId, now) + ]) + .When(state => + state.AddProduct( + FakeProductPriceCalculator.Returning(Price), + ProductItem(), + now + ) + ) + .Then( + new ProductItemAddedToShoppingCart( + shoppingCartId, + new PricedProductItem + { + ProductId = ProductItem().ProductId, Quantity = ProductItem().Quantity, UnitPrice = Price + }, + now + ) + ); + + + [Fact] + public void AddsProductItemToNonEmptyShoppingCart() => + Spec.Given([ + new ShoppingCartOpened(shoppingCartId, clientId, now), + new ProductItemAddedToShoppingCart(shoppingCartId, PricedProductItem(), now), + ]) + .When(state => + state.AddProduct( + FakeProductPriceCalculator.Returning(OtherPrice), + OtherProductItem(), + now + ) + ) + .Then( + new ProductItemAddedToShoppingCart( + shoppingCartId, + new PricedProductItem + { + ProductId = OtherProductItem().ProductId, + Quantity = OtherProductItem().Quantity, + UnitPrice = OtherPrice + }, + now + ) + ); + + [Fact] + public void CantAddProductItemToConfirmedShoppingCart() => + Spec.Given([ + new ShoppingCartOpened(shoppingCartId, clientId, now), + new ProductItemAddedToShoppingCart(shoppingCartId, PricedProductItem(), now), + new ShoppingCartConfirmed(shoppingCartId, now) + ]) + .When(state => + state.AddProduct( + FakeProductPriceCalculator.Returning(Price), + ProductItem(), + now + ) + ) + .ThenThrows(); + + [Fact] + public void CantAddProductItemToCanceledShoppingCart() => + Spec.Given([ + new ShoppingCartOpened(shoppingCartId, clientId, now), + new ProductItemAddedToShoppingCart(shoppingCartId, PricedProductItem(), now), + new ShoppingCartCanceled(shoppingCartId, now) + ]) + .When(state => + state.AddProduct( + FakeProductPriceCalculator.Returning(Price), + ProductItem(), + now + ) + ) + .ThenThrows(); + + // Remove + [Fact] + public void CantRemoveProductItemFromNotExistingShoppingCart() => + Spec.Given([]) + .When(state => + state.RemoveProduct(PricedProductItem(), now) + ) + .ThenThrows(); + + [Fact] + public void RemovesExistingProductItemFromShoppingCart() => + Spec.Given([ + new ShoppingCartOpened(shoppingCartId, clientId, now), + new ProductItemAddedToShoppingCart(shoppingCartId, PricedProductItem(), now), + ]) + .When(state => + state.RemoveProduct(PricedProductItemWithQuantity(1), now) + ) + .Then( + new ProductItemRemovedFromShoppingCart( + shoppingCartId, + PricedProductItemWithQuantity(1), + now + ) + ); + + [Fact] + public void CantRemoveNonExistingProductItemFromNonEmptyShoppingCart() => + Spec.Given([ + new ShoppingCartOpened(shoppingCartId, clientId, now), + new ProductItemAddedToShoppingCart(shoppingCartId, OtherPricedProductItem(), now), + ]) + .When(state => + state.RemoveProduct(PricedProductItemWithQuantity(1), now) + ) + .ThenThrows(); + + [Fact] + public void CantRemoveExistingProductItemFromCanceledShoppingCart() => + Spec.Given([ + new ShoppingCartOpened(shoppingCartId, clientId, now), + new ProductItemAddedToShoppingCart(shoppingCartId, PricedProductItem(), now), + new ShoppingCartConfirmed(shoppingCartId, now) + ]) + .When(state => + state.RemoveProduct(PricedProductItemWithQuantity(1), now) + ) + .ThenThrows(); + + [Fact] + public void CantRemoveExistingProductItemFromConfirmedShoppingCart() => + Spec.Given([ + new ShoppingCartOpened(shoppingCartId, clientId, now), + new ProductItemAddedToShoppingCart(shoppingCartId, PricedProductItem(), now), + new ShoppingCartCanceled(shoppingCartId, now) + ]) + .When(state => + state.RemoveProduct(PricedProductItemWithQuantity(1), now) + ) + .ThenThrows(); + + // Confirm + [Fact] + public void CantConfirmNotExistingShoppingCart() => + Spec.Given([]) + .When(state => + state.Confirm(now) + ) + .ThenThrows(); + + [Fact] + [Trait("Category", "SkipCI")] + public void CantConfirmEmptyShoppingCart() => + Spec.Given([ + new ShoppingCartOpened(shoppingCartId, clientId, now), + ]) + .When(state => + state.Confirm(now) + ) + .ThenThrows(); + + [Fact] + public void ConfirmsNonEmptyShoppingCart() => + Spec.Given([ + new ShoppingCartOpened(shoppingCartId, clientId, now), + new ProductItemAddedToShoppingCart(shoppingCartId, PricedProductItem(), now), + ]) + .When(state => + state.Confirm(now) + ) + .Then( + new ShoppingCartConfirmed(shoppingCartId, now) + ); + + [Fact] + public void CantConfirmAlreadyConfirmedShoppingCart() => + Spec.Given([ + new ShoppingCartOpened(shoppingCartId, clientId, now), + new ProductItemAddedToShoppingCart(shoppingCartId, PricedProductItem(), now), + new ShoppingCartConfirmed(shoppingCartId, now) + ]) + .When(state => + state.Confirm(now) + ) + .ThenThrows(); + + [Fact] + public void CantConfirmCanceledShoppingCart() => + Spec.Given([ + new ShoppingCartOpened(shoppingCartId, clientId, now), + new ProductItemAddedToShoppingCart(shoppingCartId, PricedProductItem(), now), + new ShoppingCartCanceled(shoppingCartId, now) + ]) + .When(state => + state.Confirm(now) + ) + .ThenThrows(); + + // Cancel + [Fact] + [Trait("Category", "SkipCI")] + public void CantCancelNotExistingShoppingCart() => + Spec.Given([]) + .When(state => + state.Cancel(now) + ) + .ThenThrows(); + + [Fact] + public void CancelsEmptyShoppingCart() => + Spec.Given([ + new ShoppingCartOpened(shoppingCartId, clientId, now), + ]) + .When(state => + state.Cancel(now) + ) + .ThenThrows(); + + [Fact] + public void CancelsNonEmptyShoppingCart() => + Spec.Given([ + new ShoppingCartOpened(shoppingCartId, clientId, now), + new ProductItemAddedToShoppingCart(shoppingCartId, PricedProductItem(), now), + ]) + .When(state => + state.Cancel(now) + ) + .Then( + new ShoppingCartCanceled(shoppingCartId, now) + ); + + [Fact] + public void CantCancelAlreadyCanceledShoppingCart() => + Spec.Given([ + new ShoppingCartOpened(shoppingCartId, clientId, now), + new ProductItemAddedToShoppingCart(shoppingCartId, PricedProductItem(), now), + new ShoppingCartCanceled(shoppingCartId, now) + ]) + .When(state => + state.Cancel(now) + ) + .ThenThrows(); + + [Fact] + public void CantCancelConfirmedShoppingCart() => + Spec.Given([ + new ShoppingCartOpened(shoppingCartId, clientId, now), + new ProductItemAddedToShoppingCart(shoppingCartId, PricedProductItem(), now), + new ShoppingCartConfirmed(shoppingCartId, now) + ]) + .When(state => + state.Cancel(now) + ) + .ThenThrows(); + + private readonly DateTimeOffset now = DateTimeOffset.Now; + private readonly Guid clientId = Guid.NewGuid(); + private readonly Guid shoppingCartId = Guid.NewGuid(); + private static readonly Guid ProductId = Guid.NewGuid(); + private static readonly Guid OtherProductId = Guid.NewGuid(); + private static readonly int Quantity = Random.Shared.Next(1, 1000); + private static readonly int OtherQuantity = Random.Shared.Next(1, 1000); + private static readonly int Price = Random.Shared.Next(1, 1000); + private static readonly int OtherPrice = Random.Shared.Next(1, 1000); + + private static readonly Func ProductItem = () => + new ProductItem { ProductId = ProductId, Quantity = Quantity }; + + private static readonly Func OtherProductItem = () => + new ProductItem { ProductId = OtherProductId, Quantity = OtherQuantity }; + + private static readonly Func PricedProductItem = () => new PricedProductItem + { + ProductId = ProductId, Quantity = Quantity, UnitPrice = Price + }; + + private static readonly Func PricedProductItemWithQuantity = + quantity => new PricedProductItem { ProductId = ProductId, Quantity = quantity, UnitPrice = Price }; + + private static readonly Func OtherPricedProductItem = () => new PricedProductItem + { + ProductId = OtherProductId, Quantity = OtherQuantity, UnitPrice = Price + }; + + private readonly HandlerSpecification Spec = + Specification.For(Evolve, ShoppingCart.Initial); + + private static readonly Func Evolve = (cart, @event) => + { + cart.Evolve(@event); + return cart; + }; +} + +public static class TestsExtensions +{ + public static ThenDeciderSpecificationBuilder When( + this WhenDeciderSpecificationBuilder, TEvent, TState> builder, + Func when + ) where TState : Aggregate => + builder.When(() => + { + var state = when(); + + return state.DequeueUncommittedEvents(); + }); + + public static ThenDeciderSpecificationBuilder When( + this WhenDeciderSpecificationBuilder, TEvent, TState> builder, + Action when + ) where TState : Aggregate => + builder.When(state => + { + when(state); + + return state.DequeueUncommittedEvents(); + }); +} diff --git a/Workshops/IntroductionToEventSourcing/Solved/07-BusinessLogic.Slimmed/Mutable/PricingCalculator.cs b/Workshops/IntroductionToEventSourcing/Solved/07-BusinessLogic.Slimmed/Mutable/PricingCalculator.cs new file mode 100644 index 000000000..e805ce8ea --- /dev/null +++ b/Workshops/IntroductionToEventSourcing/Solved/07-BusinessLogic.Slimmed/Mutable/PricingCalculator.cs @@ -0,0 +1,21 @@ +namespace IntroductionToEventSourcing.BusinessLogic.Slimmed.Mutable; + +public interface IProductPriceCalculator +{ + PricedProductItem Calculate(ProductItem productItems); +} + +public class FakeProductPriceCalculator: IProductPriceCalculator +{ + private readonly int value; + + private FakeProductPriceCalculator(int value) + { + this.value = value; + } + + public static FakeProductPriceCalculator Returning(int value) => new(value); + + public PricedProductItem Calculate(ProductItem productItem) => + new() { ProductId = productItem.ProductId, Quantity = productItem.Quantity, UnitPrice = value }; +} diff --git a/Workshops/IntroductionToEventSourcing/Solved/07-BusinessLogic.Slimmed/Mutable/ShoppingCart.cs b/Workshops/IntroductionToEventSourcing/Solved/07-BusinessLogic.Slimmed/Mutable/ShoppingCart.cs new file mode 100644 index 000000000..7a2c97efe --- /dev/null +++ b/Workshops/IntroductionToEventSourcing/Solved/07-BusinessLogic.Slimmed/Mutable/ShoppingCart.cs @@ -0,0 +1,240 @@ +namespace IntroductionToEventSourcing.BusinessLogic.Slimmed.Mutable; + +using static ShoppingCartEvent; + +// EVENTS +public abstract record ShoppingCartEvent +{ + public record ShoppingCartOpened( + Guid ShoppingCartId, + Guid ClientId, + DateTimeOffset OpenedAt + ): ShoppingCartEvent; + + public record ProductItemAddedToShoppingCart( + Guid ShoppingCartId, + PricedProductItem ProductItem, + DateTimeOffset AddedAt + ): ShoppingCartEvent; + + public record ProductItemRemovedFromShoppingCart( + Guid ShoppingCartId, + PricedProductItem ProductItem, + DateTimeOffset RemovedAt + ): ShoppingCartEvent; + + public record ShoppingCartConfirmed( + Guid ShoppingCartId, + DateTimeOffset ConfirmedAt + ): ShoppingCartEvent; + + public record ShoppingCartCanceled( + Guid ShoppingCartId, + DateTimeOffset CanceledAt + ): ShoppingCartEvent; + + // This won't allow external inheritance + private ShoppingCartEvent() { } +} + +// VALUE OBJECTS +public class PricedProductItem +{ + public Guid ProductId { get; set; } + public decimal UnitPrice { get; set; } + public int Quantity { get; set; } + public decimal TotalPrice => Quantity * UnitPrice; +} + +public class ProductItem +{ + public Guid ProductId { get; set; } + public int Quantity { get; set; } +} + +// ENTITY +public class ShoppingCart: Aggregate +{ + public Guid ClientId { get; private set; } + public ShoppingCartStatus Status { get; private set; } + public IList ProductItems { get; } = new List(); + public DateTimeOffset OpenedAt { get; private set; } + public DateTimeOffset? ConfirmedAt { get; private set; } + public DateTimeOffset? CanceledAt { get; private set; } + + public bool IsClosed => ShoppingCartStatus.Closed.HasFlag(Status); + + public override void Evolve(ShoppingCartEvent @event) + { + switch (@event) + { + case ShoppingCartOpened opened: + Apply(opened); + break; + case ProductItemAddedToShoppingCart productItemAdded: + Apply(productItemAdded); + break; + case ProductItemRemovedFromShoppingCart productItemRemoved: + Apply(productItemRemoved); + break; + case ShoppingCartConfirmed confirmed: + Apply(confirmed); + break; + case ShoppingCartCanceled canceled: + Apply(canceled); + break; + } + } + + public static ShoppingCart Open(Guid cartId, Guid clientId, DateTimeOffset now) + { + return new ShoppingCart(cartId, clientId, now); + } + + public static ShoppingCart Initial() => new(); + + private ShoppingCart(Guid id, Guid clientId, DateTimeOffset now) + { + var @event = new ShoppingCartOpened(id, clientId, now); + + Enqueue(@event); + Apply(@event); + } + + //just for default creation of empty object + private ShoppingCart() { } + + private void Apply(ShoppingCartOpened opened) + { + Id = opened.ShoppingCartId; + ClientId = opened.ClientId; + Status = ShoppingCartStatus.Pending; + } + + public void AddProduct( + IProductPriceCalculator productPriceCalculator, + ProductItem productItem, + DateTimeOffset now + ) + { + if (IsClosed) + throw new InvalidOperationException( + $"Adding product item for cart in '{Status}' status is not allowed."); + + var pricedProductItem = productPriceCalculator.Calculate(productItem); + + var @event = new ProductItemAddedToShoppingCart(Id, pricedProductItem, now); + + Enqueue(@event); + Apply(@event); + } + + private void Apply(ProductItemAddedToShoppingCart productItemAdded) + { + var pricedProductItem = productItemAdded.ProductItem; + var productId = pricedProductItem.ProductId; + var quantityToAdd = pricedProductItem.Quantity; + + var current = ProductItems.SingleOrDefault( + pi => pi.ProductId == productId + ); + + if (current == null) + ProductItems.Add(pricedProductItem); + else + ProductItems[ProductItems.IndexOf(current)] = new PricedProductItem + { + Quantity = current.Quantity + quantityToAdd, ProductId = productId, UnitPrice = current.UnitPrice + }; + } + + public void RemoveProduct(PricedProductItem productItemToBeRemoved, DateTimeOffset now) + { + if (IsClosed) + throw new InvalidOperationException( + $"Removing product item for cart in '{Status}' status is not allowed."); + + if (!HasEnough(productItemToBeRemoved)) + throw new InvalidOperationException("Not enough product items to remove"); + + var @event = new ProductItemRemovedFromShoppingCart(Id, productItemToBeRemoved, now); + + Enqueue(@event); + Apply(@event); + } + + private bool HasEnough(PricedProductItem productItem) + { + var currentQuantity = ProductItems.Where(pi => pi.ProductId == productItem.ProductId) + .Select(pi => pi.Quantity) + .FirstOrDefault(); + + return currentQuantity >= productItem.Quantity; + } + + private void Apply(ProductItemRemovedFromShoppingCart productItemRemoved) + { + var pricedProductItem = productItemRemoved.ProductItem; + var productId = pricedProductItem.ProductId; + var quantityToRemove = pricedProductItem.Quantity; + + var current = ProductItems.Single( + pi => pi.ProductId == productId + ); + + if (current.Quantity == quantityToRemove) + ProductItems.Remove(current); + else ProductItems[ProductItems.IndexOf(current)] = new PricedProductItem + { + Quantity = current.Quantity - quantityToRemove, ProductId = productId, UnitPrice = current.UnitPrice + }; + } + + public void Confirm(DateTimeOffset now) + { + if (IsClosed) + throw new InvalidOperationException( + $"Confirming cart in '{Status}' status is not allowed."); + + if (ProductItems.Count == 0) + throw new InvalidOperationException($"Cannot confirm empty shopping cart"); + + var @event = new ShoppingCartConfirmed(Id, now); + + Enqueue(@event); + Apply(@event); + } + + private void Apply(ShoppingCartConfirmed confirmed) + { + Status = ShoppingCartStatus.Confirmed; + ConfirmedAt = confirmed.ConfirmedAt; + } + + public void Cancel(DateTimeOffset now) + { + if (IsClosed) + throw new InvalidOperationException( + $"Canceling cart in '{Status}' status is not allowed."); + + var @event = new ShoppingCartCanceled(Id, now); + + Enqueue(@event); + Apply(@event); + } + + private void Apply(ShoppingCartCanceled canceled) + { + Status = ShoppingCartStatus.Canceled; + CanceledAt = canceled.CanceledAt; + } +} + +public enum ShoppingCartStatus +{ + Pending = 1, + Confirmed = 2, + Canceled = 4, + + Closed = Confirmed | Canceled +} diff --git a/Workshops/IntroductionToEventSourcing/Solved/07-BusinessLogic.Slimmed/README.md b/Workshops/IntroductionToEventSourcing/Solved/07-BusinessLogic.Slimmed/README.md new file mode 100644 index 000000000..608597f2d --- /dev/null +++ b/Workshops/IntroductionToEventSourcing/Solved/07-BusinessLogic.Slimmed/README.md @@ -0,0 +1,28 @@ +# Exercise 07 - Business Logic + +Having the following shopping cart process: +1. The customer may add a product to the shopping cart only after opening it. +2. When selecting and adding a product to the basket customer needs to provide the quantity chosen. The product price is calculated by the system based on the current price list. +3. The customer may remove a product with a given price from the cart. +4. The customer can confirm the shopping cart and start the order fulfilment process. +5. The customer may also cancel the shopping cart and reject all selected products. +6. After shopping cart confirmation or cancellation, the product can no longer be added or removed from the cart. + +Write the code that fulfils this logic. Remember that in Event Sourcing each business operation has to result with a new business fact (so event). Use events and entities defined in previous exercises. + +![events](./assets/events.jpg) + +There are two variations: +- using mutable entities: [Mutable/BusinessLogicTests.cs](./Mutable/Solution1/BusinessLogicTests.cs), +- using fully immutable structures: [Immutable/BusinessLogicTests.cs](./Immutable/BusinessLogicTests.cs). + +Select your preferred approach (or both) to solve this use case. + +_**Note**: If needed update entities, events or test setup structure_ + +## Solution + +1. Immutable, with functional command handlers composition and entities as anemic data model: [Immutable/BusinessLogic.cs](./Immutable/BusinessLogic.cs). +2. Classical, mutable aggregates (rich domain model): [Mutable/Solution1/BusinessLogic.cs](./Mutable/Solution1/BusinessLogic.cs). +3. Mixed approach, mutable aggregates (rich domain model), returning events from methods, using immutable DTOs: [Mutable/Solution2/BusinessLogic.cs](./Mutable/Solution1/BusinessLogic.cs). + diff --git a/Workshops/IntroductionToEventSourcing/Solved/07-BusinessLogic.Slimmed/Tools/EventStore.cs b/Workshops/IntroductionToEventSourcing/Solved/07-BusinessLogic.Slimmed/Tools/EventStore.cs new file mode 100644 index 000000000..d910254ab --- /dev/null +++ b/Workshops/IntroductionToEventSourcing/Solved/07-BusinessLogic.Slimmed/Tools/EventStore.cs @@ -0,0 +1,24 @@ +using System.Text.Json; + +namespace IntroductionToEventSourcing.BusinessLogic.Slimmed.Tools; + +public class EventStore +{ + private readonly Dictionary> events = new(); + + public void AppendToStream(Guid streamId, IEnumerable newEvents) + { + if (!events.ContainsKey(streamId)) + events[streamId] = []; + + events[streamId].AddRange(newEvents.Select(e => (e.GetType().FullName!, JsonSerializer.Serialize(e)))); + } + + public TEvent[] ReadStream(Guid streamId) where TEvent : notnull => + events.TryGetValue(streamId, out var stream) + ? stream.Select(@event => + JsonSerializer.Deserialize(@event.Json, Type.GetType(@event.EventType)!) + ) + .Where(e => e != null).Cast().ToArray() + : []; +} diff --git a/Workshops/IntroductionToEventSourcing/Solved/07-BusinessLogic.Slimmed/assets/events.jpg b/Workshops/IntroductionToEventSourcing/Solved/07-BusinessLogic.Slimmed/assets/events.jpg new file mode 100644 index 000000000..35522f580 Binary files /dev/null and b/Workshops/IntroductionToEventSourcing/Solved/07-BusinessLogic.Slimmed/assets/events.jpg differ