Skip to content

Commit

Permalink
Added example of using Marten with Decider pattern and Evolve function
Browse files Browse the repository at this point in the history
  • Loading branch information
oskardudycz committed Aug 28, 2023
1 parent 994d9ad commit 31d5118
Show file tree
Hide file tree
Showing 5 changed files with 336 additions and 0 deletions.
@@ -0,0 +1,94 @@
using FluentAssertions;
using IntroductionToEventSourcing.BusinessLogic.Tools;
using Xunit;

namespace IntroductionToEventSourcing.BusinessLogic.Immutable.Solution4;

using static ShoppingCart;
using static ShoppingCartCommand;

public class BusinessLogicTests: MartenTest
{
[Fact]
public async Task GettingState_ForSequenceOfEvents_ShouldSucceed()
{
var shoppingCartId = ShoppingCartId.From(Guid.NewGuid());
var clientId = ClientId.From(Guid.NewGuid());
var shoesId = ProductId.From(Guid.NewGuid());
var tShirtId = ProductId.From(Guid.NewGuid());

var one = ProductQuantity.From(1);
var two = ProductQuantity.From(2);

var twoPairsOfShoes = new ProductItem(shoesId, two);
var pairOfShoes = new ProductItem(shoesId, one);
var tShirt = new ProductItem(tShirtId, one);

var shoesPrice = ProductPrice.From(100);
var tShirtPrice = ProductPrice.From(50);

var pricedPairOfShoes = new PricedProductItem(shoesId, one, shoesPrice);
var pricedTwoPairsOfShoes = new PricedProductItem(shoesId, two, shoesPrice);
var pricedTShirt = new PricedProductItem(tShirtId, one, tShirtPrice);

await DocumentSession.Decide(
shoppingCartId,
new Open(shoppingCartId, clientId, DateTimeOffset.Now),
CancellationToken.None
);

// Add two pairs of shoes
await DocumentSession.Decide(
shoppingCartId,
new AddProductItem(shoppingCartId, pricedTwoPairsOfShoes),
CancellationToken.None
);

// Add T-Shirt
await DocumentSession.Decide(
shoppingCartId,
new AddProductItem(shoppingCartId, pricedTShirt),
CancellationToken.None
);

// Remove pair of shoes
await DocumentSession.Decide(
shoppingCartId,
new RemoveProductItem(shoppingCartId, pricedPairOfShoes),
CancellationToken.None
);


var pendingShoppingCart =
await DocumentSession.Get<ShoppingCart>(shoppingCartId.Value, CancellationToken.None) as Pending;

pendingShoppingCart.Should().NotBeNull();
pendingShoppingCart!.ProductItems.Should().HaveCount(3);

pendingShoppingCart.ProductItems[0].Should()
.Be((pricedTwoPairsOfShoes.ProductId, pricedTwoPairsOfShoes.Quantity.Value));
pendingShoppingCart.ProductItems[1].Should().Be((pricedTShirt.ProductId, pricedTShirt.Quantity.Value));
pendingShoppingCart.ProductItems[2].Should().Be((pairOfShoes.ProductId, -pairOfShoes.Quantity.Value));

// Confirm
await DocumentSession.Decide(
shoppingCartId,
new Confirm(shoppingCartId, DateTimeOffset.Now),
CancellationToken.None
);

// Cancel
var exception = await Record.ExceptionAsync(() =>
DocumentSession.Decide(
shoppingCartId,
new Cancel(shoppingCartId, DateTimeOffset.Now),
CancellationToken.None
)
);
exception.Should().BeOfType<InvalidOperationException>();

var shoppingCart = await DocumentSession.Get<ShoppingCart>(shoppingCartId.Value, CancellationToken.None);

shoppingCart.Should().BeOfType<Closed>();
}
}
@@ -0,0 +1,28 @@
using Marten;

namespace IntroductionToEventSourcing.BusinessLogic.Immutable.Solution4;

public static class DocumentSessionExtensions
{
public static async Task<TEntity> Get<TEntity>(
this IDocumentSession session,
Guid id,
CancellationToken cancellationToken = default
) where TEntity : class
{
var entity = await session.Events.AggregateStreamAsync<TEntity>(id, token: cancellationToken);

return entity ?? throw new InvalidOperationException($"Entity with id {id} was not found");
}

public static Task Decide<TEntity, TCommand, TEvent>(
this IDocumentSession session,
Func<TCommand, TEntity, TEvent[]> decide,
Func<TEntity> getDefault,
Guid streamId,
TCommand command,
CancellationToken ct = default
) where TEntity : class =>
session.Events.WriteToAggregate<TEntity>(streamId, stream =>
stream.AppendMany(decide(command, stream.Aggregate ?? getDefault()).Cast<object>().ToArray()), ct);
}
@@ -0,0 +1,72 @@
namespace IntroductionToEventSourcing.BusinessLogic.Immutable.Solution4;

using static ShoppingCartEvent;

public abstract record ShoppingCartEvent
{
public record Opened(ClientId ClientId, DateTimeOffset OpenedAt): ShoppingCartEvent;

public record ProductItemAdded(PricedProductItem ProductItem): ShoppingCartEvent;

public record ProductItemRemoved(PricedProductItem ProductItem): ShoppingCartEvent;

public record Confirmed(DateTimeOffset ConfirmedAt): ShoppingCartEvent;

public record Canceled(DateTimeOffset CanceledAt): ShoppingCartEvent;
}

public record ShoppingCart
{
public record Empty: ShoppingCart;

public record Pending((ProductId ProductId, int Quantity)[] ProductItems): ShoppingCart
{
public bool HasEnough(PricedProductItem productItem) =>
ProductItems
.Where(pi => pi.ProductId == productItem.ProductId)
.Sum(pi => pi.Quantity) >= productItem.Quantity.Value;

public bool HasItems { get; } =
ProductItems.Sum(pi => pi.Quantity) <= 0;
}

public record Closed: ShoppingCart;

public ShoppingCart Apply(ShoppingCartEvent @event) =>
@event switch
{
Opened =>
new Pending(Array.Empty<(ProductId ProductId, int Quantity)>()),

ProductItemAdded (var (productId, quantity, _)) =>
this is Pending pending
? pending with
{
ProductItems = pending.ProductItems
.Concat(new[] { (productId, quantity.Value) })
.ToArray()
}
: this,

ProductItemRemoved (var (productId, quantity, _)) =>
this is Pending pending
? pending with
{
ProductItems = pending.ProductItems
.Concat(new[] { (ProductId: productId, -quantity.Value) })
.ToArray()
}
: this,

Confirmed =>
this is Pending ? new Closed() : this,

Canceled =>
this is Pending ? new Closed() : this,

_ => this
};

public Guid Id { get; set; } // Marten unfortunately forces you to have Id
private ShoppingCart() { } // Not to allow inheritance
}
@@ -0,0 +1,78 @@
using Marten;

namespace IntroductionToEventSourcing.BusinessLogic.Immutable.Solution4;

using static ShoppingCart;
using static ShoppingCartEvent;
using static ShoppingCartCommand;

public abstract record ShoppingCartCommand
{
public record Open(ShoppingCartId ShoppingCartId, ClientId ClientId, DateTimeOffset Now): ShoppingCartCommand;

public record AddProductItem(ShoppingCartId ShoppingCartId, PricedProductItem ProductItem): ShoppingCartCommand;

public record RemoveProductItem(ShoppingCartId ShoppingCartId, PricedProductItem ProductItem): ShoppingCartCommand;

public record Confirm(ShoppingCartId ShoppingCartId, DateTimeOffset Now): ShoppingCartCommand;

public record Cancel(ShoppingCartId ShoppingCartId, DateTimeOffset Now): ShoppingCartCommand;
}

// Value Objects
public static class ShoppingCartService
{
public static ShoppingCartEvent Decide(
ShoppingCartCommand command,
ShoppingCart state
) =>
command switch
{
Open open => Handle(open),
AddProductItem addProduct => Handle(addProduct, state.EnsureIsPending()),
RemoveProductItem removeProduct => Handle(removeProduct, state.EnsureIsPending()),
Confirm confirm => Handle(confirm, state.EnsureIsPending()),
Cancel cancel => Handle(cancel, state.EnsureIsPending()),
_ => throw new InvalidOperationException($"Cannot handle {command.GetType().Name} command")
};

private static Opened Handle(Open command) =>
new Opened(command.ClientId, command.Now);

private static ProductItemAdded Handle(AddProductItem command, Pending shoppingCart) =>
new ProductItemAdded(command.ProductItem);

private static ProductItemRemoved Handle(RemoveProductItem command, Pending shoppingCart) =>
shoppingCart.HasEnough(command.ProductItem)
? new ProductItemRemoved(command.ProductItem)
: throw new InvalidOperationException("Not enough product items to remove.");

private static Confirmed Handle(Confirm command, Pending shoppingCart) =>
shoppingCart.HasItems
? new Confirmed(DateTime.UtcNow)
: throw new InvalidOperationException("Shopping cart is empty!");

private static Canceled Handle(Cancel command, Pending shoppingCart) =>
new Canceled(DateTime.UtcNow);

private static Pending EnsureIsPending(this ShoppingCart shoppingCart) =>
shoppingCart as Pending ?? throw new InvalidOperationException(
$"Invalid operation for '{shoppingCart.GetType().Name}' shopping card.");
}

public static class ShoppingCartDocumentSessionExtensions
{
public static Task Decide(
this IDocumentSession session,
ShoppingCartId streamId,
ShoppingCartCommand command,
CancellationToken ct = default
) =>
session.Decide<ShoppingCart, ShoppingCartCommand, ShoppingCartEvent>(
(c, s) => new[] { ShoppingCartService.Decide(c, s) },
() => new Empty(),
streamId.Value,
command,
ct
);
}
@@ -0,0 +1,64 @@
namespace IntroductionToEventSourcing.BusinessLogic.Immutable.Solution4;

public record ProductItem(
ProductId ProductId,
ProductQuantity Quantity
);

public record PricedProductItem(
ProductId ProductId,
ProductQuantity Quantity,
ProductPrice UnitPrice
);

public record ShoppingCartId(Guid Value)
{
public static ShoppingCartId From(Guid? value) =>
(value != null && value != Guid.Empty)
? new ShoppingCartId(value.Value)
: throw new ArgumentOutOfRangeException(nameof(value));
}

public record ClientId(Guid Value)
{
public static ClientId From(Guid? value) =>
(value.HasValue && value != Guid.Empty)
? new ClientId(value.Value)
: throw new ArgumentOutOfRangeException(nameof(value));
}

public record ProductId(Guid Value)
{
public static ProductId From(Guid? value) =>
(value.HasValue && value != Guid.Empty)
? new ProductId(value.Value)
: throw new ArgumentOutOfRangeException(nameof(value));
}

public record ProductQuantity(int Value):
IComparable<ProductQuantity>,
IComparable<int>
{
public static ProductQuantity From(int? value) =>
value is > 0
? new ProductQuantity(value.Value)
: throw new ArgumentOutOfRangeException(nameof(value));

public int CompareTo(ProductQuantity? other)
{
if (ReferenceEquals(this, other)) return 0;
if (ReferenceEquals(null, other)) return 1;
return Value.CompareTo(other.Value);
}

public int CompareTo(int other) =>
Value.CompareTo(other);
}

public record ProductPrice(decimal Value)
{
public static ProductPrice From(decimal? value) =>
value is > 0
? new ProductPrice(value.Value)
: throw new ArgumentOutOfRangeException(nameof(value));
}

0 comments on commit 31d5118

Please sign in to comment.