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