Skip to content

Commit 850022e

Browse files
committed
Added example of using Marten with Decider pattern and Evolve function
1 parent 994d9ad commit 850022e

File tree

6 files changed

+338
-3
lines changed

6 files changed

+338
-3
lines changed

Workshops/IntroductionToEventSourcing/Solved/08-BusinessLogic.Marten/Immutable/Solution3/ShoppingCart.cs

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -26,9 +26,8 @@ public bool HasEnough(PricedProductItem productItem) =>
2626
.Where(pi => pi.ProductId == productItem.ProductId)
2727
.Sum(pi => pi.Quantity) >= productItem.Quantity.Value;
2828

29-
public bool HasItems =>
30-
ProductItems
31-
.Sum(pi => pi.Quantity) <= 0;
29+
public bool HasItems { get; } =
30+
ProductItems.Sum(pi => pi.Quantity) <= 0;
3231
}
3332

3433
public record Closed: ShoppingCart;
Lines changed: 94 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,94 @@
1+
using FluentAssertions;
2+
using IntroductionToEventSourcing.BusinessLogic.Tools;
3+
using Xunit;
4+
5+
namespace IntroductionToEventSourcing.BusinessLogic.Immutable.Solution4;
6+
7+
using static ShoppingCart;
8+
using static ShoppingCartCommand;
9+
10+
public class BusinessLogicTests: MartenTest
11+
{
12+
[Fact]
13+
public async Task GettingState_ForSequenceOfEvents_ShouldSucceed()
14+
{
15+
var shoppingCartId = ShoppingCartId.From(Guid.NewGuid());
16+
var clientId = ClientId.From(Guid.NewGuid());
17+
var shoesId = ProductId.From(Guid.NewGuid());
18+
var tShirtId = ProductId.From(Guid.NewGuid());
19+
20+
var one = ProductQuantity.From(1);
21+
var two = ProductQuantity.From(2);
22+
23+
var twoPairsOfShoes = new ProductItem(shoesId, two);
24+
var pairOfShoes = new ProductItem(shoesId, one);
25+
var tShirt = new ProductItem(tShirtId, one);
26+
27+
var shoesPrice = ProductPrice.From(100);
28+
var tShirtPrice = ProductPrice.From(50);
29+
30+
var pricedPairOfShoes = new PricedProductItem(shoesId, one, shoesPrice);
31+
var pricedTwoPairsOfShoes = new PricedProductItem(shoesId, two, shoesPrice);
32+
var pricedTShirt = new PricedProductItem(tShirtId, one, tShirtPrice);
33+
34+
await DocumentSession.Decide(
35+
shoppingCartId,
36+
new Open(shoppingCartId, clientId, DateTimeOffset.Now),
37+
CancellationToken.None
38+
);
39+
40+
// Add two pairs of shoes
41+
await DocumentSession.Decide(
42+
shoppingCartId,
43+
new AddProductItem(shoppingCartId, pricedTwoPairsOfShoes),
44+
CancellationToken.None
45+
);
46+
47+
// Add T-Shirt
48+
await DocumentSession.Decide(
49+
shoppingCartId,
50+
new AddProductItem(shoppingCartId, pricedTShirt),
51+
CancellationToken.None
52+
);
53+
54+
// Remove pair of shoes
55+
await DocumentSession.Decide(
56+
shoppingCartId,
57+
new RemoveProductItem(shoppingCartId, pricedPairOfShoes),
58+
CancellationToken.None
59+
);
60+
61+
62+
var pendingShoppingCart =
63+
await DocumentSession.Get<ShoppingCart>(shoppingCartId.Value, CancellationToken.None) as Pending;
64+
65+
pendingShoppingCart.Should().NotBeNull();
66+
pendingShoppingCart!.ProductItems.Should().HaveCount(3);
67+
68+
pendingShoppingCart.ProductItems[0].Should()
69+
.Be((pricedTwoPairsOfShoes.ProductId, pricedTwoPairsOfShoes.Quantity.Value));
70+
pendingShoppingCart.ProductItems[1].Should().Be((pricedTShirt.ProductId, pricedTShirt.Quantity.Value));
71+
pendingShoppingCart.ProductItems[2].Should().Be((pairOfShoes.ProductId, -pairOfShoes.Quantity.Value));
72+
73+
// Confirm
74+
await DocumentSession.Decide(
75+
shoppingCartId,
76+
new Confirm(shoppingCartId, DateTimeOffset.Now),
77+
CancellationToken.None
78+
);
79+
80+
// Cancel
81+
var exception = await Record.ExceptionAsync(() =>
82+
DocumentSession.Decide(
83+
shoppingCartId,
84+
new Cancel(shoppingCartId, DateTimeOffset.Now),
85+
CancellationToken.None
86+
)
87+
);
88+
exception.Should().BeOfType<InvalidOperationException>();
89+
90+
var shoppingCart = await DocumentSession.Get<ShoppingCart>(shoppingCartId.Value, CancellationToken.None);
91+
92+
shoppingCart.Should().BeOfType<Closed>();
93+
}
94+
}
Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
using Marten;
2+
3+
namespace IntroductionToEventSourcing.BusinessLogic.Immutable.Solution4;
4+
5+
public static class DocumentSessionExtensions
6+
{
7+
public static async Task<TEntity> Get<TEntity>(
8+
this IDocumentSession session,
9+
Guid id,
10+
CancellationToken cancellationToken = default
11+
) where TEntity : class
12+
{
13+
var entity = await session.Events.AggregateStreamAsync<TEntity>(id, token: cancellationToken);
14+
15+
return entity ?? throw new InvalidOperationException($"Entity with id {id} was not found");
16+
}
17+
18+
public static Task Decide<TEntity, TCommand, TEvent>(
19+
this IDocumentSession session,
20+
Func<TCommand, TEntity, TEvent[]> decide,
21+
Func<TEntity> getDefault,
22+
Guid streamId,
23+
TCommand command,
24+
CancellationToken ct = default
25+
) where TEntity : class =>
26+
session.Events.WriteToAggregate<TEntity>(streamId, stream =>
27+
stream.AppendMany(decide(command, stream.Aggregate ?? getDefault()).Cast<object>().ToArray()), ct);
28+
}
Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,72 @@
1+
namespace IntroductionToEventSourcing.BusinessLogic.Immutable.Solution4;
2+
3+
using static ShoppingCartEvent;
4+
5+
public abstract record ShoppingCartEvent
6+
{
7+
public record Opened(ClientId ClientId, DateTimeOffset OpenedAt): ShoppingCartEvent;
8+
9+
public record ProductItemAdded(PricedProductItem ProductItem): ShoppingCartEvent;
10+
11+
public record ProductItemRemoved(PricedProductItem ProductItem): ShoppingCartEvent;
12+
13+
public record Confirmed(DateTimeOffset ConfirmedAt): ShoppingCartEvent;
14+
15+
public record Canceled(DateTimeOffset CanceledAt): ShoppingCartEvent;
16+
}
17+
18+
public record ShoppingCart
19+
{
20+
public record Empty: ShoppingCart;
21+
22+
public record Pending((ProductId ProductId, int Quantity)[] ProductItems): ShoppingCart
23+
{
24+
public bool HasEnough(PricedProductItem productItem) =>
25+
ProductItems
26+
.Where(pi => pi.ProductId == productItem.ProductId)
27+
.Sum(pi => pi.Quantity) >= productItem.Quantity.Value;
28+
29+
public bool HasItems { get; } =
30+
ProductItems.Sum(pi => pi.Quantity) <= 0;
31+
}
32+
33+
public record Closed: ShoppingCart;
34+
35+
public ShoppingCart Apply(ShoppingCartEvent @event) =>
36+
@event switch
37+
{
38+
Opened =>
39+
new Pending(Array.Empty<(ProductId ProductId, int Quantity)>()),
40+
41+
ProductItemAdded (var (productId, quantity, _)) =>
42+
this is Pending pending
43+
? pending with
44+
{
45+
ProductItems = pending.ProductItems
46+
.Concat(new[] { (productId, quantity.Value) })
47+
.ToArray()
48+
}
49+
: this,
50+
51+
ProductItemRemoved (var (productId, quantity, _)) =>
52+
this is Pending pending
53+
? pending with
54+
{
55+
ProductItems = pending.ProductItems
56+
.Concat(new[] { (productId, -quantity.Value) })
57+
.ToArray()
58+
}
59+
: this,
60+
61+
Confirmed =>
62+
this is Pending ? new Closed() : this,
63+
64+
Canceled =>
65+
this is Pending ? new Closed() : this,
66+
67+
_ => this
68+
};
69+
70+
public Guid Id { get; set; } // Marten unfortunately forces you to have Id
71+
private ShoppingCart() { } // Not to allow inheritance
72+
}
Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,78 @@
1+
using Marten;
2+
3+
namespace IntroductionToEventSourcing.BusinessLogic.Immutable.Solution4;
4+
5+
using static ShoppingCart;
6+
using static ShoppingCartEvent;
7+
using static ShoppingCartCommand;
8+
9+
public abstract record ShoppingCartCommand
10+
{
11+
public record Open(ShoppingCartId ShoppingCartId, ClientId ClientId, DateTimeOffset Now): ShoppingCartCommand;
12+
13+
public record AddProductItem(ShoppingCartId ShoppingCartId, PricedProductItem ProductItem): ShoppingCartCommand;
14+
15+
public record RemoveProductItem(ShoppingCartId ShoppingCartId, PricedProductItem ProductItem): ShoppingCartCommand;
16+
17+
public record Confirm(ShoppingCartId ShoppingCartId, DateTimeOffset Now): ShoppingCartCommand;
18+
19+
public record Cancel(ShoppingCartId ShoppingCartId, DateTimeOffset Now): ShoppingCartCommand;
20+
}
21+
22+
// Value Objects
23+
public static class ShoppingCartService
24+
{
25+
public static ShoppingCartEvent Decide(
26+
ShoppingCartCommand command,
27+
ShoppingCart state
28+
) =>
29+
command switch
30+
{
31+
Open open => Handle(open),
32+
AddProductItem addProduct => Handle(addProduct, state.EnsureIsPending()),
33+
RemoveProductItem removeProduct => Handle(removeProduct, state.EnsureIsPending()),
34+
Confirm confirm => Handle(confirm, state.EnsureIsPending()),
35+
Cancel cancel => Handle(cancel, state.EnsureIsPending()),
36+
_ => throw new InvalidOperationException($"Cannot handle {command.GetType().Name} command")
37+
};
38+
39+
private static Opened Handle(Open command) =>
40+
new Opened(command.ClientId, command.Now);
41+
42+
private static ProductItemAdded Handle(AddProductItem command, Pending shoppingCart) =>
43+
new ProductItemAdded(command.ProductItem);
44+
45+
private static ProductItemRemoved Handle(RemoveProductItem command, Pending shoppingCart) =>
46+
shoppingCart.HasEnough(command.ProductItem)
47+
? new ProductItemRemoved(command.ProductItem)
48+
: throw new InvalidOperationException("Not enough product items to remove.");
49+
50+
private static Confirmed Handle(Confirm command, Pending shoppingCart) =>
51+
shoppingCart.HasItems
52+
? new Confirmed(DateTime.UtcNow)
53+
: throw new InvalidOperationException("Shopping cart is empty!");
54+
55+
private static Canceled Handle(Cancel command, Pending shoppingCart) =>
56+
new Canceled(DateTime.UtcNow);
57+
58+
private static Pending EnsureIsPending(this ShoppingCart shoppingCart) =>
59+
shoppingCart as Pending ?? throw new InvalidOperationException(
60+
$"Invalid operation for '{shoppingCart.GetType().Name}' shopping card.");
61+
}
62+
63+
public static class ShoppingCartDocumentSessionExtensions
64+
{
65+
public static Task Decide(
66+
this IDocumentSession session,
67+
ShoppingCartId streamId,
68+
ShoppingCartCommand command,
69+
CancellationToken ct = default
70+
) =>
71+
session.Decide<ShoppingCart, ShoppingCartCommand, ShoppingCartEvent>(
72+
(c, s) => new[] { ShoppingCartService.Decide(c, s) },
73+
() => new Empty(),
74+
streamId.Value,
75+
command,
76+
ct
77+
);
78+
}
Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
1+
namespace IntroductionToEventSourcing.BusinessLogic.Immutable.Solution4;
2+
3+
public record ProductItem(
4+
ProductId ProductId,
5+
ProductQuantity Quantity
6+
);
7+
8+
public record PricedProductItem(
9+
ProductId ProductId,
10+
ProductQuantity Quantity,
11+
ProductPrice UnitPrice
12+
);
13+
14+
public record ShoppingCartId(Guid Value)
15+
{
16+
public static ShoppingCartId From(Guid? value) =>
17+
(value != null && value != Guid.Empty)
18+
? new ShoppingCartId(value.Value)
19+
: throw new ArgumentOutOfRangeException(nameof(value));
20+
}
21+
22+
public record ClientId(Guid Value)
23+
{
24+
public static ClientId From(Guid? value) =>
25+
(value.HasValue && value != Guid.Empty)
26+
? new ClientId(value.Value)
27+
: throw new ArgumentOutOfRangeException(nameof(value));
28+
}
29+
30+
public record ProductId(Guid Value)
31+
{
32+
public static ProductId From(Guid? value) =>
33+
(value.HasValue && value != Guid.Empty)
34+
? new ProductId(value.Value)
35+
: throw new ArgumentOutOfRangeException(nameof(value));
36+
}
37+
38+
public record ProductQuantity(int Value):
39+
IComparable<ProductQuantity>,
40+
IComparable<int>
41+
{
42+
public static ProductQuantity From(int? value) =>
43+
value is > 0
44+
? new ProductQuantity(value.Value)
45+
: throw new ArgumentOutOfRangeException(nameof(value));
46+
47+
public int CompareTo(ProductQuantity? other)
48+
{
49+
if (ReferenceEquals(this, other)) return 0;
50+
if (ReferenceEquals(null, other)) return 1;
51+
return Value.CompareTo(other.Value);
52+
}
53+
54+
public int CompareTo(int other) =>
55+
Value.CompareTo(other);
56+
}
57+
58+
public record ProductPrice(decimal Value)
59+
{
60+
public static ProductPrice From(decimal? value) =>
61+
value is > 0
62+
? new ProductPrice(value.Value)
63+
: throw new ArgumentOutOfRangeException(nameof(value));
64+
}

0 commit comments

Comments
 (0)