diff --git a/EventSourcing.NetCore.sln b/EventSourcing.NetCore.sln index ba2b5266f..4f7fdf5f6 100644 --- a/EventSourcing.NetCore.sln +++ b/EventSourcing.NetCore.sln @@ -465,6 +465,8 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "11-OptimisticConcurrency.Ev EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "11-OptimisticConcurrency.EventStoreDB.Tests", "Workshops\IntroductionToEventSourcing\11-OptimisticConcurrency.EventStoreDB.Tests\11-OptimisticConcurrency.EventStoreDB.Tests.csproj", "{37E05147-F579-458D-9B4F-25B54AC078DE}" 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 Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -1043,6 +1045,10 @@ Global {37E05147-F579-458D-9B4F-25B54AC078DE}.Debug|Any CPU.Build.0 = Debug|Any CPU {37E05147-F579-458D-9B4F-25B54AC078DE}.Release|Any CPU.ActiveCfg = Release|Any CPU {37E05147-F579-458D-9B4F-25B54AC078DE}.Release|Any CPU.Build.0 = Release|Any CPU + {B7DBA2CE-DAA9-42C0-A967-E4B62297EC1D}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {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 EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE @@ -1234,6 +1240,7 @@ Global {3AE580AB-209E-4F2A-AE7E-0BB89602A338} = {14C7B928-9D6C-441A-8A1F-0C49173E73EB} {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} EndGlobalSection GlobalSection(ExtensibilityGlobals) = postSolution SolutionGuid = {A5F55604-2FF3-43B7-B657-4F18E6E95D3B} diff --git a/Workshops/IntroductionToEventSourcing/07-BusinessLogic.Slimmed/07-BusinessLogic.Slimmed.csproj b/Workshops/IntroductionToEventSourcing/07-BusinessLogic.Slimmed/07-BusinessLogic.Slimmed.csproj new file mode 100644 index 000000000..e944715a4 --- /dev/null +++ b/Workshops/IntroductionToEventSourcing/07-BusinessLogic.Slimmed/07-BusinessLogic.Slimmed.csproj @@ -0,0 +1,28 @@ + + + + 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/07-BusinessLogic.Slimmed/Immutable/BusinessLogicTests.cs b/Workshops/IntroductionToEventSourcing/07-BusinessLogic.Slimmed/Immutable/BusinessLogicTests.cs new file mode 100644 index 000000000..a2dda3494 --- /dev/null +++ b/Workshops/IntroductionToEventSourcing/07-BusinessLogic.Slimmed/Immutable/BusinessLogicTests.cs @@ -0,0 +1,108 @@ +using FluentAssertions; +using IntroductionToEventSourcing.BusinessLogic.Slimmed.Tools; +using Xunit; + +namespace IntroductionToEventSourcing.BusinessLogic.Slimmed.Immutable; + +using static ShoppingCartEvent; +using static ShoppingCartCommand; + +public static class ShoppingCartExtensions +{ + public static ShoppingCart GetShoppingCart(this EventStore eventStore, Guid shoppingCartId) => + eventStore.ReadStream(shoppingCartId).Aggregate(ShoppingCart.Default(), ShoppingCart.Evolve); +} + +public class BusinessLogicTests +{ + [Fact] + public void RunningSequenceOfBusinessLogic_ShouldGenerateSequenceOfEvents() + { + var shoppingCartId = Guid.NewGuid(); + var clientId = Guid.NewGuid(); + var shoesId = Guid.NewGuid(); + var tShirtId = Guid.NewGuid(); + var twoPairsOfShoes = new ProductItem(shoesId, 2); + var pairOfShoes = new ProductItem(shoesId, 1); + var tShirt = new ProductItem(tShirtId, 1); + + var shoesPrice = 100; + var tShirtPrice = 50; + + var pricedPairOfShoes = new PricedProductItem(shoesId, 1, shoesPrice); + var pricedTShirt = new PricedProductItem(tShirtId, 1, tShirtPrice); + + var eventStore = new EventStore(); + + // Open + ShoppingCartEvent result = + ShoppingCartService.Handle( + new OpenShoppingCart(shoppingCartId, clientId) + ); + eventStore.AppendToStream(shoppingCartId, (object[])[result]); + + // Add Two Pair of Shoes + var shoppingCart = eventStore.GetShoppingCart(shoppingCartId); + result = ShoppingCartService.Handle( + FakeProductPriceCalculator.Returning(shoesPrice), + new AddProductItemToShoppingCart(shoppingCartId, twoPairsOfShoes), + shoppingCart + ); + eventStore.AppendToStream(shoppingCartId, (object[])[result]); + + // Add T-Shirt + shoppingCart = eventStore.GetShoppingCart(shoppingCartId); + result = ShoppingCartService.Handle( + FakeProductPriceCalculator.Returning(tShirtPrice), + new AddProductItemToShoppingCart(shoppingCartId, tShirt), + shoppingCart + ); + eventStore.AppendToStream(shoppingCartId, (object[])[result]); + + // Remove a pair of shoes + shoppingCart = eventStore.GetShoppingCart(shoppingCartId); + result = ShoppingCartService.Handle( + new RemoveProductItemFromShoppingCart(shoppingCartId, pricedPairOfShoes), + shoppingCart + ); + eventStore.AppendToStream(shoppingCartId, (object[])[result]); + + // Confirm + shoppingCart = eventStore.GetShoppingCart(shoppingCartId); + result = ShoppingCartService.Handle( + new ConfirmShoppingCart(shoppingCartId), + shoppingCart + ); + eventStore.AppendToStream(shoppingCartId, (object[])[result]); + + // Try Cancel + var exception = Record.Exception(() => + { + shoppingCart = eventStore.GetShoppingCart(shoppingCartId); + result = ShoppingCartService.Handle( + new CancelShoppingCart(shoppingCartId), + shoppingCart + ); + eventStore.AppendToStream(shoppingCartId, (object[])[result]); + }); + exception.Should().BeOfType(); + + shoppingCart = eventStore.GetShoppingCart(shoppingCartId); + + shoppingCart.Id.Should().Be(shoppingCartId); + shoppingCart.ClientId.Should().Be(clientId); + shoppingCart.ProductItems.Should().HaveCount(2); + shoppingCart.Status.Should().Be(ShoppingCartStatus.Confirmed); + + shoppingCart.ProductItems[0].Should().Be(pricedPairOfShoes); + shoppingCart.ProductItems[1].Should().Be(pricedTShirt); + + var events = eventStore.ReadStream(shoppingCartId); + events.Should().HaveCount(5); + events[0].Should().BeOfType(); + events[1].Should().BeOfType(); + events[2].Should().BeOfType(); + events[3].Should().BeOfType(); + events[4].Should().BeOfType(); + } +} diff --git a/Workshops/IntroductionToEventSourcing/07-BusinessLogic.Slimmed/Immutable/PricingCalculator.cs b/Workshops/IntroductionToEventSourcing/07-BusinessLogic.Slimmed/Immutable/PricingCalculator.cs new file mode 100644 index 000000000..013d92548 --- /dev/null +++ b/Workshops/IntroductionToEventSourcing/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/07-BusinessLogic.Slimmed/Immutable/ShoppingCart.cs b/Workshops/IntroductionToEventSourcing/07-BusinessLogic.Slimmed/Immutable/ShoppingCart.cs new file mode 100644 index 000000000..300fa5219 --- /dev/null +++ b/Workshops/IntroductionToEventSourcing/07-BusinessLogic.Slimmed/Immutable/ShoppingCart.cs @@ -0,0 +1,136 @@ +namespace IntroductionToEventSourcing.BusinessLogic.Slimmed.Immutable; +using static ShoppingCartEvent; + +// EVENTS +public abstract record ShoppingCartEvent +{ + public record ShoppingCartOpened( + Guid ShoppingCartId, + Guid ClientId + ): ShoppingCartEvent; + + public record ProductItemAddedToShoppingCart( + Guid ShoppingCartId, + PricedProductItem ProductItem + ): ShoppingCartEvent; + + public record ProductItemRemovedFromShoppingCart( + Guid ShoppingCartId, + PricedProductItem ProductItem + ): ShoppingCartEvent; + + public record ShoppingCartConfirmed( + Guid ShoppingCartId, + DateTime ConfirmedAt + ): ShoppingCartEvent; + + public record ShoppingCartCanceled( + Guid ShoppingCartId, + DateTime 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, + DateTime? ConfirmedAt = null, + DateTime? 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 Default() => + new (default, default, default, []); + + public static ShoppingCart Evolve(ShoppingCart shoppingCart, ShoppingCartEvent @event) + { + return @event switch + { + ShoppingCartOpened(var shoppingCartId, var clientId) => + shoppingCart with + { + Id = shoppingCartId, + ClientId = clientId, + Status = ShoppingCartStatus.Pending + }, + 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/07-BusinessLogic.Slimmed/Immutable/ShoppingCartService.cs b/Workshops/IntroductionToEventSourcing/07-BusinessLogic.Slimmed/Immutable/ShoppingCartService.cs new file mode 100644 index 000000000..098ea10d8 --- /dev/null +++ b/Workshops/IntroductionToEventSourcing/07-BusinessLogic.Slimmed/Immutable/ShoppingCartService.cs @@ -0,0 +1,109 @@ +namespace IntroductionToEventSourcing.BusinessLogic.Slimmed.Immutable; +using static ShoppingCartEvent; +using static ShoppingCartCommand; + +public abstract record ShoppingCartCommand +{ + public record OpenShoppingCart( + Guid ShoppingCartId, + Guid ClientId + ): ShoppingCartCommand; + + public record AddProductItemToShoppingCart( + Guid ShoppingCartId, + ProductItem ProductItem + ); + + public record RemoveProductItemFromShoppingCart( + Guid ShoppingCartId, + PricedProductItem ProductItem + ); + + public record ConfirmShoppingCart( + Guid ShoppingCartId + ); + + public record CancelShoppingCart( + Guid ShoppingCartId + ): ShoppingCartCommand; + + private ShoppingCartCommand() {} +} + +public static class ShoppingCartService +{ + public static ShoppingCartOpened Handle(OpenShoppingCart command) + { + var (shoppingCartId, clientId) = command; + + return new ShoppingCartOpened( + shoppingCartId, + clientId + ); + } + + public static ProductItemAddedToShoppingCart Handle( + IProductPriceCalculator priceCalculator, + AddProductItemToShoppingCart command, + ShoppingCart shoppingCart + ) + { + var (cartId, productItem) = 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 + ); + } + + public static ProductItemRemovedFromShoppingCart Handle( + RemoveProductItemFromShoppingCart command, + ShoppingCart shoppingCart + ) + { + var (cartId, productItem) = 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 + ); + } + + 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, + DateTime.UtcNow + ); + } + + 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, + DateTime.UtcNow + ); + } +} diff --git a/Workshops/IntroductionToEventSourcing/07-BusinessLogic.Slimmed/Mixed/Aggregate.cs b/Workshops/IntroductionToEventSourcing/07-BusinessLogic.Slimmed/Mixed/Aggregate.cs new file mode 100644 index 000000000..8934d35ed --- /dev/null +++ b/Workshops/IntroductionToEventSourcing/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/07-BusinessLogic.Slimmed/Mixed/BusinessLogicTests.cs b/Workshops/IntroductionToEventSourcing/07-BusinessLogic.Slimmed/Mixed/BusinessLogicTests.cs new file mode 100644 index 000000000..926c0d29e --- /dev/null +++ b/Workshops/IntroductionToEventSourcing/07-BusinessLogic.Slimmed/Mixed/BusinessLogicTests.cs @@ -0,0 +1,98 @@ +using FluentAssertions; +using IntroductionToEventSourcing.BusinessLogic.Slimmed.Tools; +using Xunit; + +namespace IntroductionToEventSourcing.BusinessLogic.Slimmed.Mixed; + +using static ShoppingCartEvent; + +public static class ShoppingCartExtensions +{ + public static ShoppingCart GetShoppingCart(this EventStore eventStore, Guid shoppingCartId) => + eventStore.ReadStream(shoppingCartId) + .Aggregate(ShoppingCart.Initial(), (shoppingCart, @event) => + { + shoppingCart.Evolve(@event); + return shoppingCart; + }); +} + +public class BusinessLogicTests +{ + [Fact] + public void RunningSequenceOfBusinessLogic_ShouldGenerateSequenceOfEvents() + { + var shoppingCartId = Guid.NewGuid(); + var clientId = Guid.NewGuid(); + var shoesId = Guid.NewGuid(); + var tShirtId = Guid.NewGuid(); + var twoPairsOfShoes = new ProductItem { ProductId = shoesId, Quantity = 2 }; + var pairOfShoes = new ProductItem { ProductId = shoesId, Quantity = 1 }; + var tShirt = new ProductItem { ProductId = tShirtId, Quantity = 1 }; + + var shoesPrice = 100; + var tShirtPrice = 50; + + var pricedPairOfShoes = new PricedProductItem { ProductId = shoesId, Quantity = 1, UnitPrice = shoesPrice }; + var pricedTShirt = new PricedProductItem { ProductId = tShirtId, Quantity = 1, UnitPrice = tShirtPrice }; + + var eventStore = new EventStore(); + + // Open + var (opened,_) = ShoppingCart.Open(shoppingCartId, clientId); + eventStore.AppendToStream(shoppingCartId, [opened]); + + // Add Two Pair of Shoes + var shoppingCart = eventStore.GetShoppingCart(shoppingCartId); + ShoppingCartEvent result = shoppingCart.AddProduct( + FakeProductPriceCalculator.Returning(shoesPrice), + twoPairsOfShoes + ); + eventStore.AppendToStream(shoppingCartId, [result]); + + // Add T-Shirt + shoppingCart = eventStore.GetShoppingCart(shoppingCartId); + result = shoppingCart.AddProduct( + FakeProductPriceCalculator.Returning(tShirtPrice), + tShirt + ); + eventStore.AppendToStream(shoppingCartId, [result]); + + // Remove a pair of shoes + shoppingCart = eventStore.GetShoppingCart(shoppingCartId); + result = shoppingCart.RemoveProduct(pricedPairOfShoes); + eventStore.AppendToStream(shoppingCartId, [result]); + + // Confirm + shoppingCart = eventStore.GetShoppingCart(shoppingCartId); + result = shoppingCart.Confirm(); + eventStore.AppendToStream(shoppingCartId, [result]); + + // Try Cancel + var exception = Record.Exception(() => + { + shoppingCart = eventStore.GetShoppingCart(shoppingCartId); + result = shoppingCart.Cancel(); + eventStore.AppendToStream(shoppingCartId, [result]); + }); + exception.Should().BeOfType(); + + shoppingCart = eventStore.GetShoppingCart(shoppingCartId); + + shoppingCart.Id.Should().Be(shoppingCartId); + shoppingCart.ClientId.Should().Be(clientId); + shoppingCart.ProductItems.Should().HaveCount(2); + shoppingCart.Status.Should().Be(ShoppingCartStatus.Confirmed); + + shoppingCart.ProductItems[0].Should().BeEquivalentTo(pricedPairOfShoes); + shoppingCart.ProductItems[1].Should().BeEquivalentTo(pricedTShirt); + + var events = eventStore.ReadStream(shoppingCartId); + events.Should().HaveCount(5); + events[0].Should().BeOfType(); + events[1].Should().BeOfType(); + events[2].Should().BeOfType(); + events[3].Should().BeOfType(); + events[4].Should().BeOfType(); + } +} diff --git a/Workshops/IntroductionToEventSourcing/07-BusinessLogic.Slimmed/Mixed/PricingCalculator.cs b/Workshops/IntroductionToEventSourcing/07-BusinessLogic.Slimmed/Mixed/PricingCalculator.cs new file mode 100644 index 000000000..2ef3e96a5 --- /dev/null +++ b/Workshops/IntroductionToEventSourcing/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/07-BusinessLogic.Slimmed/Mixed/ShoppingCart.cs b/Workshops/IntroductionToEventSourcing/07-BusinessLogic.Slimmed/Mixed/ShoppingCart.cs new file mode 100644 index 000000000..1360276ef --- /dev/null +++ b/Workshops/IntroductionToEventSourcing/07-BusinessLogic.Slimmed/Mixed/ShoppingCart.cs @@ -0,0 +1,236 @@ +namespace IntroductionToEventSourcing.BusinessLogic.Slimmed.Mixed; + +using static ShoppingCartEvent; + +// EVENTS +public abstract record ShoppingCartEvent +{ + public record ShoppingCartOpened( + Guid ShoppingCartId, + Guid ClientId + ): ShoppingCartEvent; + + public record ProductItemAddedToShoppingCart( + Guid ShoppingCartId, + PricedProductItem ProductItem + ): ShoppingCartEvent; + + public record ProductItemRemovedFromShoppingCart( + Guid ShoppingCartId, + PricedProductItem ProductItem + ): ShoppingCartEvent; + + public record ShoppingCartConfirmed( + Guid ShoppingCartId, + DateTime ConfirmedAt + ): ShoppingCartEvent; + + public record ShoppingCartCanceled( + Guid ShoppingCartId, + DateTime 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 DateTime? ConfirmedAt { get; private set; } + public DateTime? 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) + { + var @event = new ShoppingCartOpened( + cartId, + clientId + ); + + 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) + { + 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); + + Apply(@event); + + return @event; + } + + private void Apply(ProductItemAddedToShoppingCart productItemAdded) + { + var (_, pricedProductItem) = productItemAdded; + 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)].Quantity += quantityToAdd; + } + + public ProductItemRemovedFromShoppingCart RemoveProduct(PricedProductItem productItemToBeRemoved) + { + 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); + + 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; + 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)].Quantity -= quantityToRemove; + } + + public ShoppingCartConfirmed Confirm() + { + 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, DateTime.UtcNow); + + Apply(@event); + + return @event; + } + + private void Apply(ShoppingCartConfirmed confirmed) + { + Status = ShoppingCartStatus.Confirmed; + ConfirmedAt = confirmed.ConfirmedAt; + } + + public ShoppingCartCanceled Cancel() + { + if (IsClosed) + throw new InvalidOperationException( + $"Canceling cart in '{Status}' status is not allowed."); + + var @event = new ShoppingCartCanceled(Id, DateTime.UtcNow); + + 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/07-BusinessLogic.Slimmed/Mutable/Aggregate.cs b/Workshops/IntroductionToEventSourcing/07-BusinessLogic.Slimmed/Mutable/Aggregate.cs new file mode 100644 index 000000000..12fcb73b0 --- /dev/null +++ b/Workshops/IntroductionToEventSourcing/07-BusinessLogic.Slimmed/Mutable/Aggregate.cs @@ -0,0 +1,24 @@ +namespace IntroductionToEventSourcing.BusinessLogic.Slimmed.Mutable; + +public abstract class Aggregate +{ + public Guid Id { get; protected set; } = default!; + + private readonly Queue uncommittedEvents = new(); + + public virtual void Evolve(object @event) { } + + public object[] DequeueUncommittedEvents() + { + var dequeuedEvents = uncommittedEvents.ToArray(); + + uncommittedEvents.Clear(); + + return dequeuedEvents; + } + + protected void Enqueue(object @event) + { + uncommittedEvents.Enqueue(@event); + } +} diff --git a/Workshops/IntroductionToEventSourcing/07-BusinessLogic.Slimmed/Mutable/BusinessLogicTests.cs b/Workshops/IntroductionToEventSourcing/07-BusinessLogic.Slimmed/Mutable/BusinessLogicTests.cs new file mode 100644 index 000000000..2068c5de1 --- /dev/null +++ b/Workshops/IntroductionToEventSourcing/07-BusinessLogic.Slimmed/Mutable/BusinessLogicTests.cs @@ -0,0 +1,98 @@ +using FluentAssertions; +using IntroductionToEventSourcing.BusinessLogic.Slimmed.Tools; +using Xunit; + +namespace IntroductionToEventSourcing.BusinessLogic.Slimmed.Mutable; + +using static ShoppingCartEvent; + +public static class ShoppingCartExtensions +{ + public static ShoppingCart GetShoppingCart(this EventStore eventStore, Guid shoppingCartId) => + eventStore.ReadStream(shoppingCartId) + .Aggregate(ShoppingCart.Initial(), (shoppingCart, @event) => + { + shoppingCart.Evolve(@event); + return shoppingCart; + }); +} + +public class BusinessLogicTests +{ + [Fact] + public void RunningSequenceOfBusinessLogic_ShouldGenerateSequenceOfEvents() + { + var shoppingCartId = Guid.NewGuid(); + var clientId = Guid.NewGuid(); + var shoesId = Guid.NewGuid(); + var tShirtId = Guid.NewGuid(); + var twoPairsOfShoes = new ProductItem { ProductId = shoesId, Quantity = 2 }; + var pairOfShoes = new ProductItem { ProductId = shoesId, Quantity = 1 }; + var tShirt = new ProductItem { ProductId = tShirtId, Quantity = 1 }; + + var shoesPrice = 100; + var tShirtPrice = 50; + + var pricedPairOfShoes = new PricedProductItem { ProductId = shoesId, Quantity = 1, UnitPrice = shoesPrice }; + var pricedTShirt = new PricedProductItem { ProductId = tShirtId, Quantity = 1, UnitPrice = tShirtPrice }; + + var eventStore = new EventStore(); + + // Open + var shoppingCart = ShoppingCart.Open(shoppingCartId, clientId); + eventStore.AppendToStream(shoppingCartId, shoppingCart.DequeueUncommittedEvents()); + + // Add Two Pair of Shoes + shoppingCart = eventStore.GetShoppingCart(shoppingCartId); + shoppingCart.AddProduct( + FakeProductPriceCalculator.Returning(shoesPrice), + twoPairsOfShoes + ); + eventStore.AppendToStream(shoppingCartId, shoppingCart.DequeueUncommittedEvents()); + + // Add T-Shirt + shoppingCart = eventStore.GetShoppingCart(shoppingCartId); + shoppingCart.AddProduct( + FakeProductPriceCalculator.Returning(tShirtPrice), + tShirt + ); + eventStore.AppendToStream(shoppingCartId, shoppingCart.DequeueUncommittedEvents()); + + // Remove a pair of shoes + shoppingCart = eventStore.GetShoppingCart(shoppingCartId); + shoppingCart.RemoveProduct(pricedPairOfShoes); + eventStore.AppendToStream(shoppingCartId, shoppingCart.DequeueUncommittedEvents()); + + // Confirm + shoppingCart = eventStore.GetShoppingCart(shoppingCartId); + shoppingCart.Confirm(); + eventStore.AppendToStream(shoppingCartId, shoppingCart.DequeueUncommittedEvents()); + + // Try Cancel + var exception = Record.Exception(() => + { + shoppingCart = eventStore.GetShoppingCart(shoppingCartId); + shoppingCart.Cancel(); + eventStore.AppendToStream(shoppingCartId, shoppingCart.DequeueUncommittedEvents()); + }); + exception.Should().BeOfType(); + + shoppingCart = eventStore.GetShoppingCart(shoppingCartId); + + shoppingCart.Id.Should().Be(shoppingCartId); + shoppingCart.ClientId.Should().Be(clientId); + shoppingCart.ProductItems.Should().HaveCount(2); + shoppingCart.Status.Should().Be(ShoppingCartStatus.Confirmed); + + shoppingCart.ProductItems[0].Should().BeEquivalentTo(pricedPairOfShoes); + shoppingCart.ProductItems[1].Should().BeEquivalentTo(pricedTShirt); + + var events = eventStore.ReadStream(shoppingCartId); + events.Should().HaveCount(5); + events[0].Should().BeOfType(); + events[1].Should().BeOfType(); + events[2].Should().BeOfType(); + events[3].Should().BeOfType(); + events[4].Should().BeOfType(); + } +} diff --git a/Workshops/IntroductionToEventSourcing/07-BusinessLogic.Slimmed/Mutable/PricingCalculator.cs b/Workshops/IntroductionToEventSourcing/07-BusinessLogic.Slimmed/Mutable/PricingCalculator.cs new file mode 100644 index 000000000..e805ce8ea --- /dev/null +++ b/Workshops/IntroductionToEventSourcing/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/07-BusinessLogic.Slimmed/Mutable/ShoppingCart.cs b/Workshops/IntroductionToEventSourcing/07-BusinessLogic.Slimmed/Mutable/ShoppingCart.cs new file mode 100644 index 000000000..ffccdf71c --- /dev/null +++ b/Workshops/IntroductionToEventSourcing/07-BusinessLogic.Slimmed/Mutable/ShoppingCart.cs @@ -0,0 +1,236 @@ +namespace IntroductionToEventSourcing.BusinessLogic.Slimmed.Mutable; + +using static ShoppingCartEvent; + +// EVENTS +public abstract record ShoppingCartEvent +{ + public record ShoppingCartOpened( + Guid ShoppingCartId, + Guid ClientId + ): ShoppingCartEvent; + + public record ProductItemAddedToShoppingCart( + Guid ShoppingCartId, + PricedProductItem ProductItem + ): ShoppingCartEvent; + + public record ProductItemRemovedFromShoppingCart( + Guid ShoppingCartId, + PricedProductItem ProductItem + ): ShoppingCartEvent; + + public record ShoppingCartConfirmed( + Guid ShoppingCartId, + DateTime ConfirmedAt + ): ShoppingCartEvent; + + public record ShoppingCartCanceled( + Guid ShoppingCartId, + DateTime 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 DateTime? ConfirmedAt { get; private set; } + public DateTime? CanceledAt { get; private set; } + + public bool IsClosed => ShoppingCartStatus.Closed.HasFlag(Status); + + public override 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 ShoppingCart Open( + Guid cartId, + Guid clientId) + { + return new ShoppingCart(cartId, clientId); + } + + public static ShoppingCart Initial() => new(); + + private ShoppingCart( + Guid id, + Guid clientId) + { + var @event = new ShoppingCartOpened( + id, + clientId + ); + + 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) + { + 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); + + Enqueue(@event); + Apply(@event); + } + + private void Apply(ProductItemAddedToShoppingCart productItemAdded) + { + var (_, pricedProductItem) = productItemAdded; + var productId = pricedProductItem.ProductId; + var quantityToAdd = pricedProductItem.Quantity; + + var current = ProductItems.SingleOrDefault( + pi => pi.ProductId == productId + ); + + if (current == null) + ProductItems.Add(pricedProductItem); + else + current.Quantity += quantityToAdd; + } + + public void RemoveProduct(PricedProductItem productItemToBeRemoved) + { + 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); + + 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; + var productId = pricedProductItem.ProductId; + var quantityToRemove = pricedProductItem.Quantity; + + var current = ProductItems.Single( + pi => pi.ProductId == productId + ); + + if (current.Quantity == quantityToRemove) + ProductItems.Remove(current); + else + current.Quantity -= quantityToRemove; + } + + public void Confirm() + { + 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, DateTime.UtcNow); + + Enqueue(@event); + Apply(@event); + } + + private void Apply(ShoppingCartConfirmed confirmed) + { + Status = ShoppingCartStatus.Confirmed; + ConfirmedAt = confirmed.ConfirmedAt; + } + + public void Cancel() + { + if (IsClosed) + throw new InvalidOperationException( + $"Canceling cart in '{Status}' status is not allowed."); + + var @event = new ShoppingCartCanceled(Id, DateTime.UtcNow); + + 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/07-BusinessLogic.Slimmed/README.md b/Workshops/IntroductionToEventSourcing/07-BusinessLogic.Slimmed/README.md new file mode 100644 index 000000000..608597f2d --- /dev/null +++ b/Workshops/IntroductionToEventSourcing/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/07-BusinessLogic.Slimmed/Tools/EventStore.cs b/Workshops/IntroductionToEventSourcing/07-BusinessLogic.Slimmed/Tools/EventStore.cs new file mode 100644 index 000000000..d910254ab --- /dev/null +++ b/Workshops/IntroductionToEventSourcing/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/07-BusinessLogic.Slimmed/assets/events.jpg b/Workshops/IntroductionToEventSourcing/07-BusinessLogic.Slimmed/assets/events.jpg new file mode 100644 index 000000000..35522f580 Binary files /dev/null and b/Workshops/IntroductionToEventSourcing/07-BusinessLogic.Slimmed/assets/events.jpg differ