diff --git a/Sample/EventsVersioning/Talk/EventsVersioning.Talk.sln b/Sample/EventsVersioning/Talk/EventsVersioning.Talk.sln new file mode 100644 index 00000000..150eb4d7 --- /dev/null +++ b/Sample/EventsVersioning/Talk/EventsVersioning.Talk.sln @@ -0,0 +1,22 @@ + +Microsoft Visual Studio Solution File, Format Version 12.00 +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "HotelManagement", "HotelManagement\HotelManagement.csproj", "{8B5F91C2-572B-4B2D-B67B-3EA98584888E}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "HotelManagement.Tests", "HotelManagement.Tests\HotelManagement.Tests.csproj", "{79C84CFC-D0C4-4341-B262-92A99A372242}" +EndProject +Global + GlobalSection(SolutionConfigurationPlatforms) = preSolution + Debug|Any CPU = Debug|Any CPU + Release|Any CPU = Release|Any CPU + EndGlobalSection + GlobalSection(ProjectConfigurationPlatforms) = postSolution + {8B5F91C2-572B-4B2D-B67B-3EA98584888E}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {8B5F91C2-572B-4B2D-B67B-3EA98584888E}.Debug|Any CPU.Build.0 = Debug|Any CPU + {8B5F91C2-572B-4B2D-B67B-3EA98584888E}.Release|Any CPU.ActiveCfg = Release|Any CPU + {8B5F91C2-572B-4B2D-B67B-3EA98584888E}.Release|Any CPU.Build.0 = Release|Any CPU + {79C84CFC-D0C4-4341-B262-92A99A372242}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {79C84CFC-D0C4-4341-B262-92A99A372242}.Debug|Any CPU.Build.0 = Debug|Any CPU + {79C84CFC-D0C4-4341-B262-92A99A372242}.Release|Any CPU.ActiveCfg = Release|Any CPU + {79C84CFC-D0C4-4341-B262-92A99A372242}.Release|Any CPU.Build.0 = Release|Any CPU + EndGlobalSection +EndGlobal diff --git a/Sample/EventsVersioning/Talk/HotelManagement.Tests/Downcasters/ChangedStructure.cs b/Sample/EventsVersioning/Talk/HotelManagement.Tests/Downcasters/ChangedStructure.cs new file mode 100644 index 00000000..db2ed0c9 --- /dev/null +++ b/Sample/EventsVersioning/Talk/HotelManagement.Tests/Downcasters/ChangedStructure.cs @@ -0,0 +1,80 @@ +using System.Text.Json; +using FluentAssertions; +using V1 = HotelManagement.GuestStayAccounts; + +namespace HotelManagement.Tests.Downcasters; + +public class ChangedStructure +{ + public record Money( + decimal Amount, + string Currency = "CHF" + ); + + public record PaymentRecorded( + string GuestStayAccountId, + Money Amount, + DateTimeOffset RecordedAt + ); + + public static V1.PaymentRecorded Downcast( + PaymentRecorded newEvent + ) + { + return new V1.PaymentRecorded( + newEvent.GuestStayAccountId, + newEvent.Amount.Amount, + newEvent.RecordedAt + ); + } + + public static V1.PaymentRecorded Downcast( + string newEventJson + ) + { + var newEvent = JsonDocument.Parse(newEventJson).RootElement; + + return new V1.PaymentRecorded( + newEvent.GetProperty("GuestStayAccountId").GetString()!, + newEvent.GetProperty("Amount").GetProperty("Amount").GetDecimal(), + newEvent.GetProperty("RecordedAt").GetDateTimeOffset() + ); + } + + [Fact] + public void DowncastObjects_Should_BeForwardCompatible() + { + // Given + var newEvent = new PaymentRecorded( + Guid.NewGuid().ToString(), + new Money((decimal)Random.Shared.NextDouble(), "USD"), + DateTimeOffset.Now + ); + + // When + var @event = Downcast(newEvent); + + @event.Should().NotBeNull(); + @event.GuestStayAccountId.Should().Be(newEvent.GuestStayAccountId); + @event.Amount.Should().Be(newEvent.Amount.Amount); + } + + [Fact] + public void DowncastJson_Should_BeForwardCompatible() + { + // Given + var newEvent = new PaymentRecorded( + Guid.NewGuid().ToString(), + new Money((decimal)Random.Shared.NextDouble(), "USD"), + DateTimeOffset.Now + ); + // When + var @event = Downcast( + JsonSerializer.Serialize(newEvent) + ); + + @event.Should().NotBeNull(); + @event.GuestStayAccountId.Should().Be(newEvent.GuestStayAccountId); + @event.Amount.Should().Be(newEvent.Amount.Amount); + } +} diff --git a/Sample/EventsVersioning/Talk/HotelManagement.Tests/ExplicitSerialization/ExplicitSerializationTests.cs b/Sample/EventsVersioning/Talk/HotelManagement.Tests/ExplicitSerialization/ExplicitSerializationTests.cs new file mode 100644 index 00000000..a0a70c36 --- /dev/null +++ b/Sample/EventsVersioning/Talk/HotelManagement.Tests/ExplicitSerialization/ExplicitSerializationTests.cs @@ -0,0 +1,107 @@ +using System.Text.Json; +using FluentAssertions; + +namespace HotelManagement.Tests.ExplicitSerialization; + +using static ShoppingCartEvent; + +public class ExplicitSerializationTests +{ + [Fact] + public void ShouldSerializeAndDeserializeEvents() + { + var shoppingCartId = ShoppingCartId.New(); + var clientId = ClientId.New(); + + var tShirt = ProductId.New(); + var tShirtPrice = Price.Parse(new Money(Amount.Parse(33), Currency.PLN)); + + var shoes = ProductId.New(); + var shoesPrice = Price.Parse(new Money(Amount.Parse(77), Currency.PLN)); + + var events = new ShoppingCartEvent[] + { + new ShoppingCartOpened( + shoppingCartId, + clientId + ), + new ProductItemAddedToShoppingCart( + shoppingCartId, + new PricedProductItem(tShirt, Quantity.Parse(5), tShirtPrice) + ), + new ProductItemAddedToShoppingCart( + shoppingCartId, + new PricedProductItem(shoes, Quantity.Parse(1), shoesPrice) + ), + new ProductItemRemovedFromShoppingCart( + shoppingCartId, + new PricedProductItem(tShirt, Quantity.Parse(3), tShirtPrice) + ), + new ShoppingCartConfirmed( + shoppingCartId, + LocalDateTime.Parse(DateTimeOffset.UtcNow) + ) + }; + + var serde = new ShoppingCartEventsSerde(); + + var serializedEvents = events.Select(serde.Serialize); + + var deserializedEvents = serializedEvents.Select(e => + serde.Deserialize(e.EventType, JsonDocument.Parse(e.Data.ToJsonString())) + ).ToArray(); + + for (var i = 0; i < deserializedEvents.Length; i++) + { + deserializedEvents[i].Equals(events[i]).Should().BeTrue(); + } + } + + + [Fact] + public void ShouldGetCurrentShoppingCartState() + { + var shoppingCartId = ShoppingCartId.New(); + var clientId = ClientId.New(); + + var tShirt = ProductId.New(); + var tShirtPrice = Price.Parse(new Money(Amount.Parse(33), Currency.PLN)); + + var shoes = ProductId.New(); + var shoesPrice = Price.Parse(new Money(Amount.Parse(77), Currency.PLN)); + + var events = new ShoppingCartEvent[] + { + new ShoppingCartOpened( + shoppingCartId, + clientId + ), + new ProductItemAddedToShoppingCart( + shoppingCartId, + new PricedProductItem(tShirt, Quantity.Parse(5), tShirtPrice) + ), + new ProductItemAddedToShoppingCart( + shoppingCartId, + new PricedProductItem(shoes, Quantity.Parse(1), shoesPrice) + ), + new ProductItemRemovedFromShoppingCart( + shoppingCartId, + new PricedProductItem(tShirt, Quantity.Parse(3), tShirtPrice) + ), + new ShoppingCartConfirmed( + shoppingCartId, + LocalDateTime.Parse(DateTimeOffset.UtcNow) + ) + }; + + var shoppingCart = events.Aggregate(ShoppingCart.Default, ShoppingCart.Evolve); + + shoppingCart.Id.Should().Be(shoppingCartId); + shoppingCart.ClientId.Should().Be(clientId); + shoppingCart.Status.Should().Be(ShoppingCartStatus.Confirmed); + shoppingCart.ProductItems.Should().HaveCount(2); + shoppingCart.ProductItems.Keys.Should().Contain(new[] { tShirt, shoes }); + shoppingCart.ProductItems[tShirt].Should().Be(Quantity.Parse(2)); + shoppingCart.ProductItems[shoes].Should().Be(Quantity.Parse(1)); + } +} diff --git a/Sample/EventsVersioning/Talk/HotelManagement.Tests/ExplicitSerialization/ShoppingCart.cs b/Sample/EventsVersioning/Talk/HotelManagement.Tests/ExplicitSerialization/ShoppingCart.cs new file mode 100644 index 00000000..8d0f9b46 --- /dev/null +++ b/Sample/EventsVersioning/Talk/HotelManagement.Tests/ExplicitSerialization/ShoppingCart.cs @@ -0,0 +1,407 @@ +using System.Text.Json; +using System.Text.Json.Nodes; + +namespace HotelManagement.Tests.ExplicitSerialization; + +using static ShoppingCartEvent; + +public abstract class StronglyTypedValue(T value): IEquatable> + where T : notnull +{ + public T Value { get; } = value; + + public override string ToString() => Value.ToString()!; + + public bool Equals(StronglyTypedValue? other) + { + if (ReferenceEquals(null, other)) return false; + if (ReferenceEquals(this, other)) return true; + return EqualityComparer.Default.Equals(Value, other.Value); + } + + public override bool Equals(object? obj) + { + if (ReferenceEquals(null, obj)) return false; + if (ReferenceEquals(this, obj)) return true; + if (obj.GetType() != this.GetType()) return false; + return Equals((StronglyTypedValue)obj); + } + + public override int GetHashCode() => + EqualityComparer.Default.GetHashCode(Value); + + public static bool operator ==(StronglyTypedValue? left, StronglyTypedValue? right) => + Equals(left, right); + + public static bool operator !=(StronglyTypedValue? left, StronglyTypedValue? right) => + !Equals(left, right); +} + +public class ClientId: StronglyTypedValue +{ + private ClientId(Guid value): base(value) { } + + public static readonly ClientId Unknown = new(Guid.Empty); + + public static ClientId New() => new(Guid.NewGuid()); + + public static ClientId Parse(string? value) + { + if (!Guid.TryParse(value, out var guidValue) || guidValue == Guid.Empty) + throw new ArgumentOutOfRangeException(nameof(value)); + + return new ClientId(guidValue); + } +} + +public class ProductId: StronglyTypedValue +{ + private ProductId(Guid value): base(value) { } + + public static readonly ProductId Unknown = new(Guid.Empty); + + public static ProductId New() => new(Guid.NewGuid()); + + public static ProductId Parse(string? value) + { + if (!Guid.TryParse(value, out var guidValue) || guidValue == Guid.Empty) + throw new ArgumentOutOfRangeException(nameof(value)); + + return new ProductId(guidValue); + } +} + +public class ShoppingCartId: StronglyTypedValue +{ + private ShoppingCartId(Guid value): base(value) + { + } + + public static readonly ShoppingCartId Unknown = new(Guid.Empty); + + public static ShoppingCartId New() => new(Guid.NewGuid()); + + public static ShoppingCartId Parse(string? value) + { + if (!Guid.TryParse(value, out var guidValue) || guidValue == Guid.Empty) + throw new ArgumentOutOfRangeException(nameof(value)); + + return new ShoppingCartId(guidValue); + } +} + +public enum Currency +{ + USD, + EUR, + PLN +} + +public class Amount: StronglyTypedValue, IComparable +{ + private Amount(int value): base(value) { } + public bool IsPositive => Value > 0; + + public int CompareTo(Amount? other) => Value.CompareTo(other?.Value); + + public static Amount Parse(int value) => new(value); +} + +public class Quantity: StronglyTypedValue, IComparable, IComparable +{ + private Quantity(uint value): base(value) { } + + public int CompareTo(Quantity? other) => Value.CompareTo(other?.Value); + public int CompareTo(int other) => Value.CompareTo(other); + + public static Quantity operator +(Quantity a) => a; + + public static Quantity operator -(Quantity _) => throw new InvalidOperationException(); + + public static Quantity operator +(Quantity a, Quantity b) => + new(a.Value + b.Value); + + public static Quantity operator -(Quantity a, Quantity b) => + new(a.Value - b.Value); + + public static bool operator >(Quantity a, Quantity b) + => a.Value > b.Value; + + public static bool operator >=(Quantity a, Quantity b) + => a.Value >= b.Value; + + public static bool operator <(Quantity a, Quantity b) + => a.Value < b.Value; + + public static bool operator <=(Quantity a, Quantity b) + => a.Value <= b.Value; + + public static Quantity Parse(uint value) => new(value); +} + +public class LocalDateTime: StronglyTypedValue, IComparable +{ + private LocalDateTime(DateTimeOffset value): base(value) + { + } + + public int CompareTo(LocalDateTime? other) => other != null ? Value.CompareTo(other.Value) : -1; + + + public static LocalDateTime Parse(DateTimeOffset value) => new(value); +} + +public record Money( + Amount Amount, + Currency Currency +); + +public class Price(Money value): StronglyTypedValue(value) +{ + public static Price Parse(Money value) + { + if (!value.Amount.IsPositive) + throw new ArgumentOutOfRangeException(nameof(value), "Price cannot be negative"); + + return new Price(value); + } +} + +public abstract record ShoppingCartEvent +{ + public record ShoppingCartOpened( + ShoppingCartId ShoppingCartId, + ClientId ClientId + ): ShoppingCartEvent; + + public record ProductItemAddedToShoppingCart( + ShoppingCartId ShoppingCartId, + PricedProductItem ProductItem + ): ShoppingCartEvent; + + public record ProductItemRemovedFromShoppingCart( + ShoppingCartId ShoppingCartId, + PricedProductItem ProductItem + ): ShoppingCartEvent; + + public record ShoppingCartConfirmed( + ShoppingCartId ShoppingCartId, + LocalDateTime ConfirmedAt + ): ShoppingCartEvent; + + public record ShoppingCartCanceled( + ShoppingCartId ShoppingCartId, + LocalDateTime CanceledAt + ): ShoppingCartEvent; +} + +public enum ShoppingCartStatus +{ + Pending = 1, + Confirmed = 2, + Canceled = 4, + + Closed = Confirmed | Canceled +} + +public record PricedProductItem( + ProductId ProductId, + Quantity Quantity, + Price UnitPrice +); + +public record ShoppingCart( + ShoppingCartId Id, + ClientId ClientId, + ShoppingCartStatus Status, + Dictionary ProductItems +) +{ + public static ShoppingCart Evolve(ShoppingCart entity, object @event) => + @event switch + { + ShoppingCartOpened (var cartId, var clientId) => + new ShoppingCart(cartId, clientId, ShoppingCartStatus.Pending, new Dictionary()), + + ProductItemAddedToShoppingCart (_, var productItem) => + entity with { ProductItems = entity.ProductItems.Add(productItem) }, + + ProductItemRemovedFromShoppingCart (_, var productItem) => + entity with { ProductItems = entity.ProductItems.Remove(productItem) }, + + ShoppingCartConfirmed (_, var confirmedAt) => + entity with { Status = ShoppingCartStatus.Confirmed }, + + ShoppingCartCanceled (_, var canceledAt) => + entity with { Status = ShoppingCartStatus.Canceled }, + _ => entity + }; + + public static ShoppingCart Default => + new(ShoppingCartId.Unknown, ClientId.Unknown, default, new Dictionary()); +} + +public static class ProductItemsExtensions +{ + public static Dictionary Add(this Dictionary productItems, + PricedProductItem productItem) => + productItems + .Union(new[] { new KeyValuePair(productItem.ProductId, productItem.Quantity) }) + .GroupBy(ks => ks.Key) + .ToDictionary(ks => ks.Key, ps => Quantity.Parse((uint)ps.Sum(x => x.Value.Value))); + + public static Dictionary + Remove(this Dictionary productItems, PricedProductItem productItem) => + productItems + .Select(p => + p.Key == productItem.ProductId + ? new KeyValuePair(p.Key, + Quantity.Parse(p.Value.Value - productItem.Quantity.Value)) + : p) + .Where(p => p.Value > Quantity.Parse(0)) + .ToDictionary(ks => ks.Key, ps => ps.Value); +} + +public class ShoppingCartEventsSerde +{ + public (string EventType, JsonObject Data) Serialize(ShoppingCartEvent @event) => + @event switch + { + ShoppingCartOpened e => + ("shopping_cart_opened", + Json.Object( + Json.Node("shoppingCartId", e.ShoppingCartId.ToJson()), + Json.Node("clientId", e.ClientId.ToJson() + ) + ) + ), + ProductItemAddedToShoppingCart e => + ("product_item_added_to_shopping_cart", + Json.Object( + Json.Node("shoppingCartId", e.ShoppingCartId.ToJson()), + Json.Node("productItem", e.ProductItem.ToJson()) + ) + ), + ProductItemRemovedFromShoppingCart e => + ("product_item_removed_from_shopping_cart", + Json.Object( + Json.Node("shoppingCartId", e.ShoppingCartId.ToJson()), + Json.Node("productItem", e.ProductItem.ToJson()) + ) + ), + ShoppingCartConfirmed e => + ("shopping_cart_confirmed", + Json.Object( + Json.Node("shoppingCartId", e.ShoppingCartId.ToJson()), + Json.Node("confirmedAt", e.ConfirmedAt.ToJson()) + ) + ), + ShoppingCartCanceled e => + ("shopping_cart_canceled", + Json.Object( + Json.Node("shoppingCartId", e.ShoppingCartId.ToJson()), + Json.Node("canceledAt", e.CanceledAt.ToJson()) + ) + ), + _ => throw new InvalidOperationException() + }; + + public ShoppingCartEvent Deserialize(string eventType, JsonDocument document) + { + var data = document.RootElement; + + return eventType switch + { + "shopping_cart_opened" => + new ShoppingCartOpened( + data.GetProperty("shoppingCartId").ToShoppingCartId(), + data.GetProperty("clientId").ToClientId() + ), + "product_item_added_to_shopping_cart" => + new ProductItemAddedToShoppingCart( + data.GetProperty("shoppingCartId").ToShoppingCartId(), + data.GetProperty("productItem").ToPricedProductItem() + ), + "product_item_removed_from_shopping_cart" => + new ProductItemRemovedFromShoppingCart( + data.GetProperty("shoppingCartId").ToShoppingCartId(), + data.GetProperty("productItem").ToPricedProductItem() + ), + "shopping_cart_confirmed" => + new ShoppingCartConfirmed( + data.GetProperty("shoppingCartId").ToShoppingCartId(), + data.GetProperty("confirmedAt").ToLocalDateTime() + ), + "shopping_cart_canceled" => + new ShoppingCartCanceled( + data.GetProperty("shoppingCartId").ToShoppingCartId(), + data.GetProperty("canceledAt").ToLocalDateTime() + ), + _ => throw new InvalidOperationException() + }; + } +} + +public static class Json +{ + public static JsonObject Object(params KeyValuePair[] nodes) => new(nodes); + public static KeyValuePair Node(string key, JsonNode? node) => new(key, node); + + public static JsonNode ToJson(this ShoppingCartId value) => value.Value; + public static JsonNode ToJson(this ProductId value) => value.Value; + public static JsonNode ToJson(this ClientId value) => value.Value; + public static JsonNode ToJson(this Amount value) => value.Value; + public static JsonNode ToJson(this Quantity value) => value.Value; + public static JsonNode ToJson(this LocalDateTime value) => value.Value; + + public static JsonObject ToJson(this Money value) => + Object( + Node("amount", value.Amount.ToJson()), + Node("currency", value.Currency.ToString()) + ); + + public static JsonObject ToJson(this Price value) => value.Value.ToJson(); + + public static JsonObject ToJson(this PricedProductItem value) => + Object( + Node("productId", value.ProductId.ToJson()), + Node("quantity", value.Quantity.ToJson()), + Node("unitPrice", value.UnitPrice.ToJson()) + ); + + public static ShoppingCartId ToShoppingCartId(this JsonElement value) => + ShoppingCartId.Parse(value.GetString()); + + public static ProductId ToProductId(this JsonElement value) => + ProductId.Parse(value.GetString()); + + public static ClientId ToClientId(this JsonElement value) => + ClientId.Parse(value.GetString()); + + public static Currency ToCurrency(this JsonElement value) => + Enum.Parse(value.GetString() ?? throw new ArgumentOutOfRangeException()); + + public static Amount ToAmount(this JsonElement value) => + Amount.Parse(value.GetInt32()); + + public static Quantity ToQuantity(this JsonElement value) => + Quantity.Parse(value.GetUInt32()); + + public static Money ToMoney(this JsonElement value) => + new( + value.GetProperty("amount").ToAmount(), + value.GetProperty("currency").ToCurrency() + ); + + public static LocalDateTime ToLocalDateTime(this JsonElement value) => + LocalDateTime.Parse(DateTimeOffset.Parse(value.GetString() ?? throw new ArgumentOutOfRangeException())); + + public static Price ToPrice(this JsonElement value) => new(value.ToMoney()); + + public static PricedProductItem ToPricedProductItem(this JsonElement value) => + new( + value.GetProperty("productId").ToProductId(), + value.GetProperty("quantity").ToQuantity(), + value.GetProperty("unitPrice").ToPrice() + ); +} diff --git a/Sample/EventsVersioning/Talk/HotelManagement.Tests/HotelManagement.Tests.csproj b/Sample/EventsVersioning/Talk/HotelManagement.Tests/HotelManagement.Tests.csproj new file mode 100644 index 00000000..8bd799c2 --- /dev/null +++ b/Sample/EventsVersioning/Talk/HotelManagement.Tests/HotelManagement.Tests.csproj @@ -0,0 +1,30 @@ + + + + net8.0 + + + + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + + + all + runtime; build; native; contentfiles; analyzers + + + + + + + + + + diff --git a/Sample/EventsVersioning/Talk/HotelManagement.Tests/SimpleMappings/NewNotRequiredProperty.cs b/Sample/EventsVersioning/Talk/HotelManagement.Tests/SimpleMappings/NewNotRequiredProperty.cs new file mode 100644 index 00000000..1395d770 --- /dev/null +++ b/Sample/EventsVersioning/Talk/HotelManagement.Tests/SimpleMappings/NewNotRequiredProperty.cs @@ -0,0 +1,55 @@ +using System.Text.Json; +using FluentAssertions; +using V1 = HotelManagement.GuestStayAccounts; + +namespace HotelManagement.Tests.SimpleMappings; + +public class NewNotRequiredProperty +{ + public record PaymentRecorded( + string GuestStayAccountId, + decimal Amount, + DateTimeOffset RecordedAt, + string? ClerkId = null + ); + + [Fact] + public void Should_BeForwardCompatible() + { + // Given + var oldEvent = new V1.PaymentRecorded( + Guid.NewGuid().ToString(), + (decimal)Random.Shared.NextDouble(), + DateTimeOffset.Now + ); + var json = JsonSerializer.Serialize(oldEvent); + + // When + var @event = JsonSerializer.Deserialize(json); + + @event.Should().NotBeNull(); + @event!.GuestStayAccountId.Should().Be(oldEvent.GuestStayAccountId); + @event.Amount.Should().Be(oldEvent.Amount); + @event.ClerkId.Should().BeNull(); + } + + [Fact] + public void Should_BeBackwardCompatible() + { + // Given + var @event = new PaymentRecorded( + Guid.NewGuid().ToString(), + (decimal)Random.Shared.NextDouble(), + DateTimeOffset.Now, + Guid.NewGuid().ToString() + ); + var json = JsonSerializer.Serialize(@event); + + // When + var oldEvent = JsonSerializer.Deserialize(json); + + oldEvent.Should().NotBeNull(); + oldEvent!.GuestStayAccountId.Should().Be(@event.GuestStayAccountId); + oldEvent.Amount.Should().Be(@event.Amount); + } +} diff --git a/Sample/EventsVersioning/Talk/HotelManagement.Tests/SimpleMappings/NewRequiredProperty.cs b/Sample/EventsVersioning/Talk/HotelManagement.Tests/SimpleMappings/NewRequiredProperty.cs new file mode 100644 index 00000000..ac9ca5f7 --- /dev/null +++ b/Sample/EventsVersioning/Talk/HotelManagement.Tests/SimpleMappings/NewRequiredProperty.cs @@ -0,0 +1,58 @@ +using System.Text.Json; +using FluentAssertions; +using V1 = HotelManagement.GuestStayAccounts; + +namespace HotelManagement.Tests.SimpleMappings; + +public class NewRequiredProperty +{ + public record PaymentRecorded( + string GuestStayAccountId, + decimal Amount, + DateTimeOffset RecordedAt, + string Currency = PaymentRecorded.DefaultCurrency + ) + { + public const string DefaultCurrency = "CHF"; + } + + [Fact] + public void Should_BeForwardCompatible() + { + // Given + var oldEvent = new V1.PaymentRecorded( + Guid.NewGuid().ToString(), + (decimal)Random.Shared.NextDouble(), + DateTimeOffset.Now + ); + var json = JsonSerializer.Serialize(oldEvent); + + // When + var @event = JsonSerializer.Deserialize(json); + + @event.Should().NotBeNull(); + @event!.GuestStayAccountId.Should().Be(oldEvent.GuestStayAccountId); + @event.Amount.Should().Be(oldEvent.Amount); + @event.Currency.Should().Be(PaymentRecorded.DefaultCurrency); + } + + [Fact] + public void Should_BeBackwardCompatible() + { + // Given + var @event = new PaymentRecorded( + Guid.NewGuid().ToString(), + (decimal)Random.Shared.NextDouble(), + DateTimeOffset.Now, + Guid.NewGuid().ToString() + ); + var json = JsonSerializer.Serialize(@event); + + // When + var oldEvent = JsonSerializer.Deserialize(json); + + oldEvent.Should().NotBeNull(); + oldEvent!.GuestStayAccountId.Should().Be(@event.GuestStayAccountId); + oldEvent.Amount.Should().Be(@event.Amount); + } +} diff --git a/Sample/EventsVersioning/Talk/HotelManagement.Tests/SimpleMappings/RenamedProperty.cs b/Sample/EventsVersioning/Talk/HotelManagement.Tests/SimpleMappings/RenamedProperty.cs new file mode 100644 index 00000000..c9bfc5b1 --- /dev/null +++ b/Sample/EventsVersioning/Talk/HotelManagement.Tests/SimpleMappings/RenamedProperty.cs @@ -0,0 +1,60 @@ +using System.Text.Json; +using System.Text.Json.Serialization; +using FluentAssertions; +using V1 = HotelManagement.GuestStayAccounts; + +namespace HotelManagement.Tests.SimpleMappings; + +public class RenamedProperty +{ + public record PaymentRecorded( + [property: JsonPropertyName("GuestStayAccountId")] + string AccountId, + decimal Amount, + DateTimeOffset RecordedAt, + string Currency = PaymentRecorded.DefaultCurrency + ) + { + public const string DefaultCurrency = "CHF"; + } + + [Fact] + public void Should_BeForwardCompatible() + { + // Given + var oldEvent = new V1.PaymentRecorded( + Guid.NewGuid().ToString(), + (decimal)Random.Shared.NextDouble(), + DateTimeOffset.Now + ); + var json = JsonSerializer.Serialize(oldEvent); + + // When + var @event = JsonSerializer.Deserialize(json); + + @event.Should().NotBeNull(); + @event!.AccountId.Should().Be(oldEvent.GuestStayAccountId); + @event.Amount.Should().Be(oldEvent.Amount); + @event.Currency.Should().Be(PaymentRecorded.DefaultCurrency); + } + + [Fact] + public void Should_BeBackwardCompatible() + { + // Given + var @event = new PaymentRecorded( + Guid.NewGuid().ToString(), + (decimal)Random.Shared.NextDouble(), + DateTimeOffset.Now, + Guid.NewGuid().ToString() + ); + var json = JsonSerializer.Serialize(@event); + + // When + var oldEvent = JsonSerializer.Deserialize(json); + + oldEvent.Should().NotBeNull(); + oldEvent!.GuestStayAccountId.Should().Be(@event.AccountId); + oldEvent.Amount.Should().Be(@event.Amount); + } +} diff --git a/Sample/EventsVersioning/Talk/HotelManagement.Tests/SnapshotTesting/EventsSnapshotTests.ShoppingCartConfirmed_WithCompleteData_IsCompatible.verified.txt b/Sample/EventsVersioning/Talk/HotelManagement.Tests/SnapshotTesting/EventsSnapshotTests.ShoppingCartConfirmed_WithCompleteData_IsCompatible.verified.txt new file mode 100644 index 00000000..b86b595d --- /dev/null +++ b/Sample/EventsVersioning/Talk/HotelManagement.Tests/SnapshotTesting/EventsSnapshotTests.ShoppingCartConfirmed_WithCompleteData_IsCompatible.verified.txt @@ -0,0 +1,6 @@ +{ + GuestStayAccountId: Guid_1, + Amount: 292.333, + RecordedAt: DateTimeOffset_1, + ClerkId: anonymised +} \ No newline at end of file diff --git a/Sample/EventsVersioning/Talk/HotelManagement.Tests/SnapshotTesting/EventsSnapshotTests.ShoppingCartConfirmed_WithOnlyRequiredData_IsCompatible.verified.txt b/Sample/EventsVersioning/Talk/HotelManagement.Tests/SnapshotTesting/EventsSnapshotTests.ShoppingCartConfirmed_WithOnlyRequiredData_IsCompatible.verified.txt new file mode 100644 index 00000000..6834737f --- /dev/null +++ b/Sample/EventsVersioning/Talk/HotelManagement.Tests/SnapshotTesting/EventsSnapshotTests.ShoppingCartConfirmed_WithOnlyRequiredData_IsCompatible.verified.txt @@ -0,0 +1,5 @@ +{ + GuestStayAccountId: Guid_1, + Amount: 292.333, + RecordedAt: DateTimeOffset_1 +} \ No newline at end of file diff --git a/Sample/EventsVersioning/Talk/HotelManagement.Tests/SnapshotTesting/EventsSnapshotTests.cs b/Sample/EventsVersioning/Talk/HotelManagement.Tests/SnapshotTesting/EventsSnapshotTests.cs new file mode 100644 index 00000000..af4e7d7a --- /dev/null +++ b/Sample/EventsVersioning/Talk/HotelManagement.Tests/SnapshotTesting/EventsSnapshotTests.cs @@ -0,0 +1,45 @@ +using System.Runtime.CompilerServices; + +namespace EventsVersioning.Tests.SnapshotTesting; + +public class EventsSnapshotTests +{ + public record PaymentRecorded( + string GuestStayAccountId, + decimal Amount, + DateTimeOffset RecordedAt, + string? ClerkId = null + ); + + [Fact] + public Task ShoppingCartConfirmed_WithCompleteData_IsCompatible() + { + var @event = new PaymentRecorded( + Guid.NewGuid().ToString(), + 292.333m, + DateTimeOffset.UtcNow, + "Oskar Dudycz" + ); + return Verify(@event); + } + + [Fact] + public Task ShoppingCartConfirmed_WithOnlyRequiredData_IsCompatible() + { + var @event = new PaymentRecorded( + Guid.NewGuid().ToString(), + 292.333m, + DateTimeOffset.UtcNow + ); + return Verify(@event); + } +} + +// note this is optional, if you really need to +// This is just showing that you can +public static class StaticSettingsUsage +{ + [ModuleInitializer] + public static void Initialize() => + VerifierSettings.AddScrubber(text => text.Replace("Oskar Dudycz", "anonymised")); +} diff --git a/Sample/EventsVersioning/Talk/HotelManagement.Tests/SnapshotTesting/PackageSnapshotTests.cs b/Sample/EventsVersioning/Talk/HotelManagement.Tests/SnapshotTesting/PackageSnapshotTests.cs new file mode 100644 index 00000000..6a1a7834 --- /dev/null +++ b/Sample/EventsVersioning/Talk/HotelManagement.Tests/SnapshotTesting/PackageSnapshotTests.cs @@ -0,0 +1,15 @@ +using HotelManagement.GuestStayAccounts; +using PublicApiGenerator; + +namespace EventsVersioning.Tests.SnapshotTesting; + +public class PackageSnapshotTests +{ + [Fact] + public Task my_assembly_has_no_public_api_changes() + { + var publicApi = typeof(GuestCheckedIn).Assembly.GeneratePublicApi(); + + return Verify(publicApi); + } +} diff --git a/Sample/EventsVersioning/Talk/HotelManagement.Tests/SnapshotTesting/PackageSnapshotTests.my_assembly_has_no_public_api_changes.verified.txt b/Sample/EventsVersioning/Talk/HotelManagement.Tests/SnapshotTesting/PackageSnapshotTests.my_assembly_has_no_public_api_changes.verified.txt new file mode 100644 index 00000000..6b8082db --- /dev/null +++ b/Sample/EventsVersioning/Talk/HotelManagement.Tests/SnapshotTesting/PackageSnapshotTests.my_assembly_has_no_public_api_changes.verified.txt @@ -0,0 +1,172 @@ +[assembly: System.Runtime.Versioning.TargetFramework(".NETCoreApp,Version=v8.0", FrameworkDisplayName=".NET 8.0")] +namespace HotelManagement.EventStore +{ + public class CommandHandler + { + public CommandHandler(System.Func evolve, System.Func getInitial) { } + public System.Threading.Tasks.Task Handle(HotelManagement.EventStore.IEventStore eventStore, string id, System.Func handle, System.Threading.CancellationToken ct) { } + } + public class EventSerializer : HotelManagement.EventStore.IEventSerializer + { + public EventSerializer(HotelManagement.EventStore.EventTypeMapping mapping, HotelManagement.EventStore.EventTransformations transformations, HotelManagement.EventStore.StreamTransformations? streamTransformations = null) { } + public object? Deserialize(HotelManagement.EventStore.SerializedEvent serializedEvent) { } + public System.Collections.Generic.List Deserialize(System.Collections.Generic.List events) { } + public HotelManagement.EventStore.SerializedEvent Serialize(object @event) { } + } + public class EventTransformations + { + public EventTransformations() { } + public HotelManagement.EventStore.EventTransformations Register(string eventTypeName, System.Func transformJson) + where TEvent : notnull { } + public HotelManagement.EventStore.EventTransformations Register(string eventTypeName, System.Func transformEvent) + where TOldEvent : notnull + where TEvent : notnull { } + public bool TryTransform(string eventTypeName, string json, out object? result) { } + } + public class EventTypeMapping + { + public EventTypeMapping() { } + public HotelManagement.EventStore.EventTypeMapping CustomMap(System.Type eventType, params string[] eventTypeNames) { } + public HotelManagement.EventStore.EventTypeMapping CustomMap(params string[] eventTypeNames) { } + public string ToName(System.Type eventType) { } + public string ToName() { } + public System.Type? ToType(string eventTypeName) { } + } + public interface IEventSerializer + { + object? Deserialize(HotelManagement.EventStore.SerializedEvent serializedEvent); + System.Collections.Generic.List Deserialize(System.Collections.Generic.List events); + HotelManagement.EventStore.SerializedEvent Serialize(object @event); + } + public interface IEventStore + { + System.Threading.Tasks.ValueTask AppendToStream(string streamId, System.Collections.Generic.IEnumerable newEvents, System.Threading.CancellationToken ct = default); + System.Threading.Tasks.ValueTask ReadStream(string streamId, System.Threading.CancellationToken ct = default); + } + public class InMemoryEventStore : HotelManagement.EventStore.IEventStore + { + public InMemoryEventStore(HotelManagement.EventStore.EventSerializer eventSerializer) { } + public System.Threading.Tasks.ValueTask AppendToStream(string streamId, System.Collections.Generic.IEnumerable newEvents, System.Threading.CancellationToken _ = default) { } + public System.Threading.Tasks.ValueTask ReadStream(string streamId, System.Threading.CancellationToken _ = default) { } + } + public class SerializedEvent : System.IEquatable + { + public SerializedEvent(string EventType, string Data, string MetaData = "") { } + public string Data { get; init; } + public string EventType { get; init; } + public string MetaData { get; init; } + } + public class StreamTransformations + { + public StreamTransformations() { } + public HotelManagement.EventStore.StreamTransformations Register(System.Func, System.Collections.Generic.List> transformJson) { } + public System.Collections.Generic.List Transform(System.Collections.Generic.List events) { } + } +} +namespace HotelManagement.GuestStayAccounts +{ + public class ChargeRecorded : System.IEquatable + { + public ChargeRecorded(string GuestStayAccountId, decimal Amount, System.DateTimeOffset RecordedAt) { } + public decimal Amount { get; init; } + public string GuestStayAccountId { get; init; } + public System.DateTimeOffset RecordedAt { get; init; } + } + public class CheckIn : System.IEquatable + { + public CheckIn(string ClerkId, string GuestStayId, string RoomId, System.DateTimeOffset Now) { } + public string ClerkId { get; init; } + public string GuestStayId { get; init; } + public System.DateTimeOffset Now { get; init; } + public string RoomId { get; init; } + } + public class CheckOut : System.IEquatable + { + public CheckOut(string ClerkId, string GuestStayAccountId, System.DateTimeOffset Now) { } + public string ClerkId { get; init; } + public string GuestStayAccountId { get; init; } + public System.DateTimeOffset Now { get; init; } + } + public class GuestCheckedIn : System.IEquatable + { + public GuestCheckedIn(string GuestStayAccountId, string GuestStayId, string RoomId, string ClerkId, System.DateTimeOffset CheckedInAt) { } + public System.DateTimeOffset CheckedInAt { get; init; } + public string ClerkId { get; init; } + public string GuestStayAccountId { get; init; } + public string GuestStayId { get; init; } + public string RoomId { get; init; } + } + public class GuestCheckedOut : System.IEquatable + { + public GuestCheckedOut(string GuestStayAccountId, string ClerkId, System.DateTimeOffset CheckedOutAt) { } + public System.DateTimeOffset CheckedOutAt { get; init; } + public string ClerkId { get; init; } + public string GuestStayAccountId { get; init; } + } + public class GuestCheckoutFailed : System.IEquatable + { + public GuestCheckoutFailed(string GuestStayAccountId, string ClerkId, HotelManagement.GuestStayAccounts.GuestCheckoutFailed.FailureReason Reason, System.DateTimeOffset FailedAt) { } + public string ClerkId { get; init; } + public System.DateTimeOffset FailedAt { get; init; } + public string GuestStayAccountId { get; init; } + public HotelManagement.GuestStayAccounts.GuestCheckoutFailed.FailureReason Reason { get; init; } + public enum FailureReason + { + NotOpened = 0, + BalanceNotSettled = 1, + } + } + public class GuestStayAccount : System.IEquatable + { + public static readonly HotelManagement.GuestStayAccounts.GuestStayAccount Initial; + public GuestStayAccount(string Id, [System.Runtime.CompilerServices.DecimalConstant(0, 0, 0u, 0u, 0u)] decimal Balance, HotelManagement.GuestStayAccounts.GuestStayAccountStatus Status = 1) { } + public bool IsSettled { get; } + public decimal Balance { get; init; } + public string Id { get; init; } + public HotelManagement.GuestStayAccounts.GuestStayAccountStatus Status { get; init; } + public static HotelManagement.GuestStayAccounts.GuestStayAccount Evolve(HotelManagement.GuestStayAccounts.GuestStayAccount state, object @event) { } + public static string GuestStayAccountId(string guestStayId, string roomId, System.DateOnly checkInDate) { } + } + public static class GuestStayAccountDecider + { + public static HotelManagement.GuestStayAccounts.GuestCheckedIn CheckIn(HotelManagement.GuestStayAccounts.CheckIn command, HotelManagement.GuestStayAccounts.GuestStayAccount state) { } + public static object CheckOut(HotelManagement.GuestStayAccounts.CheckOut command, HotelManagement.GuestStayAccounts.GuestStayAccount state) { } + public static HotelManagement.GuestStayAccounts.ChargeRecorded RecordCharge(HotelManagement.GuestStayAccounts.RecordCharge command, HotelManagement.GuestStayAccounts.GuestStayAccount state) { } + public static HotelManagement.GuestStayAccounts.PaymentRecorded RecordPayment(HotelManagement.GuestStayAccounts.RecordPayment command, HotelManagement.GuestStayAccounts.GuestStayAccount state) { } + } + public class GuestStayAccountService + { + public GuestStayAccountService(HotelManagement.EventStore.IEventStore eventStore) { } + public System.Threading.Tasks.Task CheckIn(HotelManagement.GuestStayAccounts.CheckIn command, System.Threading.CancellationToken ct = default) { } + public System.Threading.Tasks.Task CheckOut(HotelManagement.GuestStayAccounts.CheckOut command, System.Threading.CancellationToken ct = default) { } + public System.Threading.Tasks.Task RecordCharge(HotelManagement.GuestStayAccounts.RecordCharge command, System.Threading.CancellationToken ct = default) { } + public System.Threading.Tasks.Task RecordPayment(HotelManagement.GuestStayAccounts.RecordPayment command, System.Threading.CancellationToken ct = default) { } + } + public enum GuestStayAccountStatus + { + NotExisting = 0, + Opened = 1, + CheckedOut = 2, + } + public class PaymentRecorded : System.IEquatable + { + public PaymentRecorded(string GuestStayAccountId, decimal Amount, System.DateTimeOffset RecordedAt) { } + public decimal Amount { get; init; } + public string GuestStayAccountId { get; init; } + public System.DateTimeOffset RecordedAt { get; init; } + } + public class RecordCharge : System.IEquatable + { + public RecordCharge(string GuestStayAccountId, decimal Amount, System.DateTimeOffset Now) { } + public decimal Amount { get; init; } + public string GuestStayAccountId { get; init; } + public System.DateTimeOffset Now { get; init; } + } + public class RecordPayment : System.IEquatable + { + public RecordPayment(string GuestStayAccountId, decimal Amount, System.DateTimeOffset Now) { } + public decimal Amount { get; init; } + public string GuestStayAccountId { get; init; } + public System.DateTimeOffset Now { get; init; } + } +} \ No newline at end of file diff --git a/Sample/EventsVersioning/Talk/HotelManagement.Tests/Transformations/MultipleTransformationsWithDifferentEventTypes.cs b/Sample/EventsVersioning/Talk/HotelManagement.Tests/Transformations/MultipleTransformationsWithDifferentEventTypes.cs new file mode 100644 index 00000000..7aaf4c99 --- /dev/null +++ b/Sample/EventsVersioning/Talk/HotelManagement.Tests/Transformations/MultipleTransformationsWithDifferentEventTypes.cs @@ -0,0 +1,127 @@ +using System.Text.Json; +using FluentAssertions; +using HotelManagement.EventStore; +using V1 = HotelManagement.GuestStayAccounts; + +namespace HotelManagement.Tests.Transformations +{ + public record Money( + decimal Amount, + string Currency + ); + + namespace V2 + { + public record PaymentRecorded( + string GuestStayAccountId, + Money Amount, + DateTimeOffset RecordedAt + ); + } + + public record PaymentRecorded( + string GuestStayAccountId, + Money Amount, + DateTimeOffset RecordedAt, + string ClerkId + ); + + public class MultipleTransformationsWithDifferentEventTypes + { + public static PaymentRecorded UpcastV1( + JsonDocument oldEventJson + ) + { + var oldEvent = oldEventJson.RootElement; + + return new PaymentRecorded( + oldEvent.GetProperty("GuestStayAccountId").GetString()!, + new Money(oldEvent.GetProperty("Amount").GetDecimal(), "CHF"), + oldEvent.GetProperty("RecordedAt").GetDateTimeOffset(), + "" + ); + } + + public static PaymentRecorded UpcastV2( + V2.PaymentRecorded oldEvent + ) => + new( + oldEvent.GuestStayAccountId, + oldEvent.Amount, + oldEvent.RecordedAt, + "" + ); + + [Fact] + public void UpcastObjects_Should_BeForwardCompatible() + { + // Given + const string eventTypeV1Name = "payment_recorded_v1"; + const string eventTypeV2Name = "payment_recorded_v2"; + const string eventTypeV3Name = "payment_recorded_v3"; + + var mapping = new EventTypeMapping() + .CustomMap( + eventTypeV1Name, + eventTypeV2Name, + eventTypeV3Name + ); + + var transformations = new EventTransformations() + .Register(eventTypeV1Name, UpcastV1) + .Register(eventTypeV2Name, UpcastV2); + + var serializer = new EventSerializer(mapping, transformations); + + var eventV1 = new V1.PaymentRecorded( + Guid.NewGuid().ToString(), + (decimal)Random.Shared.NextDouble(), + DateTimeOffset.Now + ); + var eventV2 = new V2.PaymentRecorded( + Guid.NewGuid().ToString(), + new Money((decimal)Random.Shared.NextDouble(), "USD"), + DateTimeOffset.Now + ); + var eventV3 = new PaymentRecorded( + Guid.NewGuid().ToString(), + new Money((decimal)Random.Shared.NextDouble(), "EUR"), + DateTimeOffset.Now, + Guid.NewGuid().ToString() + ); + + var events = new[] + { + new SerializedEvent(eventTypeV1Name, JsonSerializer.Serialize(eventV1)), + new SerializedEvent(eventTypeV2Name, JsonSerializer.Serialize(eventV2)), + new SerializedEvent(eventTypeV3Name, JsonSerializer.Serialize(eventV3)) + }; + + // When + var deserializedEvents = events + .Select(serializer.Deserialize) + .OfType() + .ToList(); + + deserializedEvents.Should().HaveCount(3); + + // Then + deserializedEvents[0].GuestStayAccountId.Should().Be(eventV1.GuestStayAccountId); + deserializedEvents[0].Amount.Should().Be(new Money(eventV1.Amount, "CHF")); + deserializedEvents[0].ClerkId.Should().Be(""); + deserializedEvents[0].RecordedAt.Should().Be(eventV1.RecordedAt); + + + deserializedEvents[1].GuestStayAccountId.Should().Be(eventV2.GuestStayAccountId); + deserializedEvents[1].Amount.Should().Be(eventV2.Amount); + deserializedEvents[1].ClerkId.Should().Be(""); + deserializedEvents[1].RecordedAt.Should().Be(eventV2.RecordedAt); + + + deserializedEvents[2].GuestStayAccountId.Should().Be(eventV3.GuestStayAccountId); + deserializedEvents[2].Amount.Should().Be(eventV3.Amount); + deserializedEvents[2].ClerkId.Should().Be(eventV3.ClerkId); + deserializedEvents[2].RecordedAt.Should().Be(eventV3.RecordedAt); + } + } +} diff --git a/Sample/EventsVersioning/Talk/HotelManagement.Tests/Transformations/StreamTransformations.cs b/Sample/EventsVersioning/Talk/HotelManagement.Tests/Transformations/StreamTransformations.cs new file mode 100644 index 00000000..8f227055 --- /dev/null +++ b/Sample/EventsVersioning/Talk/HotelManagement.Tests/Transformations/StreamTransformations.cs @@ -0,0 +1,282 @@ +// using System.Text.Json; +// using FluentAssertions; +// using V1 = ECommerce.V1; +// +// namespace HotelManagement.Tests.Transformations; +// +// public class MergeEvents +// { +// public record ShoppingCartInitializedWithProducts( +// Guid ShoppingCartId, +// Guid ClientId, +// List ProductItems +// ); +// +// public record EventMetadata( +// Guid CorrelationId +// ); +// +// public record EventData( +// string EventType, +// string Data, +// string MetaData +// ); +// +// public List FlattenInitializedEventsWithProductItemsAdded( +// List events +// ) +// { +// var cartOpened = events.First(); +// var cartInitializedCorrelationId = +// JsonSerializer.Deserialize(cartOpened.MetaData)! +// .CorrelationId; +// +// var i = 1; +// List productItemsAdded = []; +// +// while (i < events.Count) +// { +// var eventData = events[i]; +// +// if (eventData.EventType != "product_item_added_v1") +// break; +// +// var correlationId = JsonSerializer +// .Deserialize(eventData.MetaData)! +// .CorrelationId; +// +// if (correlationId != cartInitializedCorrelationId) +// break; +// +// productItemsAdded.Add(eventData); +// i++; +// } +// +// var mergedEvent = ToShoppingCartInitializedWithProducts( +// cartOpened, +// productItemsAdded +// ); +// +// return +// [ +// ..new[] { mergedEvent }.Union(events.Skip(i)) +// ]; +// } +// +// private EventData ToShoppingCartInitializedWithProducts( +// EventData shoppingCartInitialized, +// List productItemsAdded +// ) +// { +// var shoppingCartInitializedJson = JsonDocument.Parse(shoppingCartInitialized.Data).RootElement; +// +// var newEvent = new ShoppingCartInitializedWithProducts( +// shoppingCartInitializedJson.GetProperty("ShoppingCartId").GetGuid(), +// shoppingCartInitializedJson.GetProperty("ClientId").GetGuid(), [ +// +// ..productItemsAdded.Select(pi => +// { +// var pricedProductItem = JsonDocument.Parse(pi.Data).RootElement.GetProperty("ProductItem"); +// var productItem = pricedProductItem.GetProperty("ProductItem"); +// +// return new V1.PricedProductItem( +// new V1.ProductItem(productItem.GetProperty("ProductId").GetGuid(), +// productItem.GetProperty("Quantity").GetInt32()), +// pricedProductItem.GetProperty("UnitPrice").GetDecimal()); +// }) +// +// ] +// ); +// +// return new EventData("shopping_cart_initialized_v2", JsonSerializer.Serialize(newEvent), +// shoppingCartInitialized.MetaData); +// } +// +// public class StreamTransformations +// { +// private readonly List, List>> jsonTransformations = []; +// +// public List Transform(List events) +// { +// if (!jsonTransformations.Any()) +// return events; +// +// var result = jsonTransformations +// .Aggregate(events, (current, transform) => transform(current)); +// +// return result; +// } +// +// public StreamTransformations Register( +// Func, List> transformJson +// ) +// { +// jsonTransformations.Add(transformJson); +// return this; +// } +// } +// +// public class EventTransformations +// { +// private readonly Dictionary> jsonTransformations = new(); +// +// public bool TryTransform(string eventTypeName, string json, out object? result) +// { +// if (!jsonTransformations.TryGetValue(eventTypeName, out var transformJson)) +// { +// result = null; +// return false; +// } +// +// result = transformJson(json); +// return true; +// } +// +// public EventTransformations Register(string eventTypeName, Func transformJson) +// where TEvent : notnull +// { +// jsonTransformations.Add( +// eventTypeName, +// json => transformJson(JsonDocument.Parse(json)) +// ); +// return this; +// } +// +// public EventTransformations Register(string eventTypeName, +// Func transformEvent) +// where TOldEvent : notnull +// where TEvent : notnull +// { +// jsonTransformations.Add( +// eventTypeName, +// json => transformEvent(JsonSerializer.Deserialize(json)!) +// ); +// return this; +// } +// } +// +// public class EventTypeMapping +// { +// private readonly Dictionary mappings = new(); +// +// public EventTypeMapping Register(params string[] typeNames) +// { +// var eventType = typeof(TEvent); +// +// foreach (var typeName in typeNames) +// { +// mappings.Add(typeName, eventType); +// } +// +// return this; +// } +// +// public Type Map(string eventType) => mappings[eventType]; +// } +// +// public class EventSerializer( +// EventTypeMapping mapping, +// StreamTransformations streamTransformations, +// EventTransformations transformations) +// { +// public object? Deserialize(string eventTypeName, string json) => +// transformations.TryTransform(eventTypeName, json, out var transformed) +// ? transformed +// : JsonSerializer.Deserialize(json, mapping.Map(eventTypeName)); +// +// public List Deserialize(List events) => +// streamTransformations.Transform(events) +// .Select(@event => Deserialize(@event.EventType, @event.Data)) +// .ToList(); +// } +// +// [Fact] +// public void UpcastObjects_Should_BeForwardCompatible() +// { +// // Given +// var mapping = new EventTypeMapping() +// .Register( +// "shopping_cart_initialized_v2" +// ) +// .Register( +// "product_item_added_v1" +// ) +// .Register( +// "shopping_card_confirmed_v1" +// ); +// +// var streamTransformations = +// new StreamTransformations() +// .Register(FlattenInitializedEventsWithProductItemsAdded); +// +// var serializer = new EventSerializer( +// mapping, +// streamTransformations, +// new EventTransformations() +// ); +// +// var shoppingCardId = Guid.NewGuid(); +// var clientId = Guid.NewGuid(); +// var theSameCorrelationId = Guid.NewGuid(); +// var productItem = new V1.PricedProductItem(new V1.ProductItem(Guid.NewGuid(), 1), 23.22m); +// +// var events = new (string EventTypeName, object EventData, EventMetadata MetaData)[] +// { +// ( +// "shopping_cart_initialized_v1", +// new V1.ShoppingCartOpened(shoppingCardId, clientId), +// new EventMetadata(theSameCorrelationId) +// ), +// ( +// "product_item_added_v1", +// new V1.ProductItemAddedToShoppingCart(shoppingCardId, productItem), +// new EventMetadata(theSameCorrelationId) +// ), +// ( +// "product_item_added_v1", +// new V1.ProductItemAddedToShoppingCart(shoppingCardId, productItem), +// new EventMetadata(theSameCorrelationId) +// ), +// ( +// "product_item_added_v1", +// new V1.ProductItemAddedToShoppingCart(shoppingCardId, productItem), +// new EventMetadata(Guid.NewGuid()) +// ), +// ( +// "shopping_card_confirmed_v1", +// new V1.ShoppingCartConfirmed(shoppingCardId, DateTime.UtcNow), +// new EventMetadata(Guid.NewGuid()) +// ) +// }; +// +// var serialisedEvents = events.Select(e => +// new EventData( +// e.EventTypeName, +// JsonSerializer.Serialize(e.EventData), +// JsonSerializer.Serialize(e.MetaData) +// ) +// ).ToList(); +// +// // When +// var deserializedEvents = serializer.Deserialize(serialisedEvents); +// +// // Then +// deserializedEvents.Should().HaveCount(3); +// deserializedEvents[0].As() +// .ClientId.Should().Be(clientId); +// deserializedEvents[0].As() +// .ShoppingCartId.Should().Be(shoppingCardId); +// deserializedEvents[0].As() +// .ProductItems.Should().HaveCount(2); +// deserializedEvents[0].As() +// .ProductItems.Should().OnlyContain(pr => pr.Equals(productItem)); +// +// deserializedEvents[1].As() +// .ShoppingCartId.Should().Be(shoppingCardId); +// deserializedEvents[1].As() +// .ProductItem.Should().Be(productItem); +// +// deserializedEvents[2].As() +// .ShoppingCartId.Should().Be(shoppingCardId); +// } +// } diff --git a/Sample/EventsVersioning/Talk/HotelManagement.Tests/Upcasters/ChangedStructure.cs b/Sample/EventsVersioning/Talk/HotelManagement.Tests/Upcasters/ChangedStructure.cs new file mode 100644 index 00000000..19089e75 --- /dev/null +++ b/Sample/EventsVersioning/Talk/HotelManagement.Tests/Upcasters/ChangedStructure.cs @@ -0,0 +1,81 @@ +using System.Text.Json; +using FluentAssertions; +using V1 = HotelManagement.GuestStayAccounts; + +namespace HotelManagement.Tests.Upcasters; + +public class ChangedStructure +{ + public record Money( + decimal Amount, + string Currency = "CHF" + ); + + public record PaymentRecorded( + string GuestStayAccountId, + Money Amount, + DateTimeOffset RecordedAt + ); + + public static PaymentRecorded Upcast( + V1.PaymentRecorded newEvent + ) + { + return new PaymentRecorded( + newEvent.GuestStayAccountId, + new Money(newEvent.Amount), + newEvent.RecordedAt + ); + } + + public static PaymentRecorded Upcast( + string oldEventJson + ) + { + var oldEvent = JsonDocument.Parse(oldEventJson).RootElement; + + return new PaymentRecorded( + oldEvent.GetProperty("GuestStayAccountId").GetString()!, + new Money(oldEvent.GetProperty("Amount").GetDecimal()), + oldEvent.GetProperty("RecordedAt").GetDateTimeOffset() + ); + } + + [Fact] + public void UpcastObjects_Should_BeForwardCompatible() + { + // Given + var oldEvent = new V1.PaymentRecorded( + Guid.NewGuid().ToString(), + (decimal)Random.Shared.NextDouble(), + DateTimeOffset.Now + ); + + // When + var @event = Upcast(oldEvent); + + @event.Should().NotBeNull(); + @event.GuestStayAccountId.Should().Be(oldEvent.GuestStayAccountId); + @event.Amount.Should().Be(new Money(oldEvent.Amount, "CHF")); + } + + [Fact] + public void UpcastJson_Should_BeForwardCompatible() + { + // Given + var oldEvent = new V1.PaymentRecorded( + Guid.NewGuid().ToString(), + (decimal)Random.Shared.NextDouble(), + DateTimeOffset.Now + ); + + // When + var @event = Upcast( + JsonSerializer.Serialize(oldEvent) + ); + + @event.Should().NotBeNull(); + @event.GuestStayAccountId.Should().Be(oldEvent.GuestStayAccountId); + @event.Amount.Should().Be(new Money(oldEvent.Amount, "CHF")); + } +} diff --git a/Sample/EventsVersioning/Talk/HotelManagement.Tests/Upcasters/NewRequiredPropertyFromMetadata.cs b/Sample/EventsVersioning/Talk/HotelManagement.Tests/Upcasters/NewRequiredPropertyFromMetadata.cs new file mode 100644 index 00000000..4bb8df63 --- /dev/null +++ b/Sample/EventsVersioning/Talk/HotelManagement.Tests/Upcasters/NewRequiredPropertyFromMetadata.cs @@ -0,0 +1,92 @@ +using System.Text.Json; +using FluentAssertions; +using V1 = HotelManagement.GuestStayAccounts; + +namespace HotelManagement.Tests.Upcasters; + +public class NewRequiredPropertyFromMetadata +{ + public record EventMetadata( + string UserId + ); + + public record PaymentRecorded( + string GuestStayAccountId, + decimal Amount, + DateTimeOffset Now, + string ClerkId + ); + + public static PaymentRecorded Upcast( + V1.PaymentRecorded newEvent, + EventMetadata eventMetadata + ) + { + return new PaymentRecorded( + newEvent.GuestStayAccountId, + newEvent.Amount, + newEvent.RecordedAt, + eventMetadata.UserId + ); + } + + public static PaymentRecorded Upcast( + string oldEventJson, + string eventMetadataJson + ) + { + var oldEvent = JsonDocument.Parse(oldEventJson).RootElement; + var eventMetadata = JsonDocument.Parse(eventMetadataJson).RootElement; + + return new PaymentRecorded( + oldEvent.GetProperty("GuestStayAccountId").GetString()!, + oldEvent.GetProperty("Amount").GetDecimal(), + oldEvent.GetProperty("RecordedAt").GetDateTimeOffset(), + eventMetadata.GetProperty("UserId").GetString()! + ); + } + + [Fact] + public void UpcastObjects_Should_BeForwardCompatible() + { + // Given + var oldEvent = new V1.PaymentRecorded( + Guid.NewGuid().ToString(), + (decimal)Random.Shared.NextDouble(), + DateTimeOffset.Now + ); + var eventMetadata = new EventMetadata(Guid.NewGuid().ToString()); + + // When + var @event = Upcast(oldEvent, eventMetadata); + + @event.Should().NotBeNull(); + @event.Should().NotBeNull(); + @event.GuestStayAccountId.Should().Be(oldEvent.GuestStayAccountId); + @event.Amount.Should().Be(oldEvent.Amount); + @event.ClerkId.Should().Be(eventMetadata.UserId); + } + + [Fact] + public void UpcastJson_Should_BeForwardCompatible() + { + // Given + var oldEvent = new V1.PaymentRecorded( + Guid.NewGuid().ToString(), + (decimal)Random.Shared.NextDouble(), + DateTimeOffset.Now + ); + var eventMetadata = new EventMetadata(Guid.NewGuid().ToString()); + + // When + var @event = Upcast( + JsonSerializer.Serialize(oldEvent), + JsonSerializer.Serialize(eventMetadata) + ); + + @event.Should().NotBeNull(); + @event.GuestStayAccountId.Should().Be(oldEvent.GuestStayAccountId); + @event.Amount.Should().Be(oldEvent.Amount); + @event.ClerkId.Should().Be(eventMetadata.UserId); + } +} diff --git a/Sample/EventsVersioning/Talk/HotelManagement/EventStore/CommandHandler.cs b/Sample/EventsVersioning/Talk/HotelManagement/EventStore/CommandHandler.cs new file mode 100644 index 00000000..7a071e0a --- /dev/null +++ b/Sample/EventsVersioning/Talk/HotelManagement/EventStore/CommandHandler.cs @@ -0,0 +1,23 @@ +namespace HotelManagement.EventStore; + +public class CommandHandler( + Func evolve, + Func getInitial +) +{ + public async Task Handle( + IEventStore eventStore, + string id, + Func handle, + CancellationToken ct + ) + { + var events = await eventStore.ReadStream(id, ct); + + var state = events.Aggregate(getInitial(), evolve); + + var result = handle(state); + + await eventStore.AppendToStream(id, result, ct); + } +} diff --git a/Sample/EventsVersioning/Talk/HotelManagement/EventStore/EventSerializer.cs b/Sample/EventsVersioning/Talk/HotelManagement/EventStore/EventSerializer.cs new file mode 100644 index 00000000..6526fece --- /dev/null +++ b/Sample/EventsVersioning/Talk/HotelManagement/EventStore/EventSerializer.cs @@ -0,0 +1,34 @@ +using System.Text.Json; + +namespace HotelManagement.EventStore; + +public interface IEventSerializer +{ + SerializedEvent Serialize(object @event); + object? Deserialize(SerializedEvent serializedEvent); + List Deserialize(List events); +} + +public class EventSerializer( + EventTypeMapping mapping, + EventTransformations transformations, + StreamTransformations? streamTransformations = null +): IEventSerializer +{ + private readonly StreamTransformations streamTransformations = streamTransformations ?? new StreamTransformations(); + + public SerializedEvent Serialize(object @event) => + new(mapping.ToName(@event.GetType()), JsonSerializer.Serialize(@event)); + + public object? Deserialize(SerializedEvent serializedEvent) => + transformations.TryTransform(serializedEvent.EventType, serializedEvent.Data, out var transformed) + ? transformed + : mapping.ToType(serializedEvent.EventType) is { } eventType + ? JsonSerializer.Deserialize(serializedEvent.Data, eventType) + : null; + + public List Deserialize(List events) => + streamTransformations.Transform(events) + .Select(Deserialize) + .ToList(); +} diff --git a/Sample/EventsVersioning/Talk/HotelManagement/EventStore/EventTransformations.cs b/Sample/EventsVersioning/Talk/HotelManagement/EventStore/EventTransformations.cs new file mode 100644 index 00000000..6043c89b --- /dev/null +++ b/Sample/EventsVersioning/Talk/HotelManagement/EventStore/EventTransformations.cs @@ -0,0 +1,42 @@ +using System.Text.Json; + +namespace HotelManagement.EventStore; + +public class EventTransformations +{ + private readonly Dictionary> jsonTransformations = new(); + + public bool TryTransform(string eventTypeName, string json, out object? result) + { + if (!jsonTransformations.TryGetValue(eventTypeName, out var transformJson)) + { + result = null; + return false; + } + + result = transformJson(json); + return true; + } + + public EventTransformations Register(string eventTypeName, Func transformJson) + where TEvent : notnull + { + jsonTransformations.Add( + eventTypeName, + json => transformJson(JsonDocument.Parse(json)) + ); + return this; + } + + public EventTransformations Register(string eventTypeName, + Func transformEvent) + where TOldEvent : notnull + where TEvent : notnull + { + jsonTransformations.Add( + eventTypeName, + json => transformEvent(JsonSerializer.Deserialize(json)!) + ); + return this; + } +} diff --git a/Sample/EventsVersioning/Talk/HotelManagement/EventStore/EventTypeMapping.cs b/Sample/EventsVersioning/Talk/HotelManagement/EventStore/EventTypeMapping.cs new file mode 100644 index 00000000..22c56e1e --- /dev/null +++ b/Sample/EventsVersioning/Talk/HotelManagement/EventStore/EventTypeMapping.cs @@ -0,0 +1,52 @@ +using System.Collections.Concurrent; + +namespace HotelManagement.EventStore; + +public class EventTypeMapping +{ + private readonly ConcurrentDictionary typeMap = new(); + private readonly ConcurrentDictionary typeNameMap = new(); + + public EventTypeMapping CustomMap(params string[] eventTypeNames) => CustomMap(typeof(T), eventTypeNames); + + public EventTypeMapping CustomMap(Type eventType, params string[] eventTypeNames) + { + foreach (var eventTypeName in eventTypeNames) + { + typeNameMap.AddOrUpdate(eventType, eventTypeName, (_, typeName) => typeName); + typeMap.AddOrUpdate(eventTypeName, eventType, (_, type) => type); + } + + return this; + } + + public string ToName() => ToName(typeof(TEventType)); + + public string ToName(Type eventType) => + typeNameMap.GetOrAdd(eventType, _ => + { + var eventTypeName = eventType.FullName!; + + typeMap.TryAdd(eventTypeName, eventType); + + return eventTypeName; + }); + + public Type? ToType(string eventTypeName) => + typeMap.GetOrAdd(eventTypeName, _ => + { + var type = GetFirstMatchingTypeFromCurrentDomainAssembly(eventTypeName); + + if (type == null) + return null; + + typeNameMap.TryAdd(type, eventTypeName); + + return type; + }); + + private static Type? GetFirstMatchingTypeFromCurrentDomainAssembly(string typeName) => + AppDomain.CurrentDomain.GetAssemblies() + .SelectMany(a => a.GetTypes().Where(x => x.FullName == typeName || x.Name == typeName)) + .FirstOrDefault(); +} diff --git a/Sample/EventsVersioning/Talk/HotelManagement/EventStore/InMemoryEventStore.cs b/Sample/EventsVersioning/Talk/HotelManagement/EventStore/InMemoryEventStore.cs new file mode 100644 index 00000000..0d0601a7 --- /dev/null +++ b/Sample/EventsVersioning/Talk/HotelManagement/EventStore/InMemoryEventStore.cs @@ -0,0 +1,54 @@ +using System.Text.Json; + +namespace HotelManagement.EventStore; + +public interface IEventStore +{ + ValueTask AppendToStream( + string streamId, + IEnumerable newEvents, + CancellationToken ct = default + ); + + ValueTask ReadStream( + string streamId, + CancellationToken ct = default + ); +} + +public record SerializedEvent( + string EventType, + string Data, + string MetaData = "" +); + +public class InMemoryEventStore(EventSerializer eventSerializer): IEventStore +{ + private readonly Dictionary> events = new(); + + public ValueTask AppendToStream(string streamId, IEnumerable newEvents, CancellationToken _ = default) + { + if (!events.ContainsKey(streamId)) + events[streamId] = []; + + var serializedEvents = newEvents.Select(eventSerializer.Serialize); + + events[streamId].AddRange(serializedEvents); + + return ValueTask.CompletedTask; + } + + public ValueTask ReadStream(string streamId, CancellationToken _ = default) + { + var streamEvents = events.TryGetValue(streamId, out var stream) + ? stream + : []; + + var deserializedEvents = eventSerializer.Deserialize(streamEvents) + .Where(e => e != null) + .Cast() + .ToArray(); + + return ValueTask.FromResult(deserializedEvents); + } +} diff --git a/Sample/EventsVersioning/Talk/HotelManagement/EventStore/StreamTransformations.cs b/Sample/EventsVersioning/Talk/HotelManagement/EventStore/StreamTransformations.cs new file mode 100644 index 00000000..8a45b7c8 --- /dev/null +++ b/Sample/EventsVersioning/Talk/HotelManagement/EventStore/StreamTransformations.cs @@ -0,0 +1,26 @@ +namespace HotelManagement.EventStore; + + +public class StreamTransformations +{ + private readonly List, List>> jsonTransformations = []; + + public List Transform(List events) + { + if (!jsonTransformations.Any()) + return events; + + var result = jsonTransformations + .Aggregate(events, (current, transform) => transform(current)); + + return result; + } + + public StreamTransformations Register( + Func, List> transformJson + ) + { + jsonTransformations.Add(transformJson); + return this; + } +} diff --git a/Sample/EventsVersioning/Talk/HotelManagement/GuestStayAccounts/ApplicationLogic.cs b/Sample/EventsVersioning/Talk/HotelManagement/GuestStayAccounts/ApplicationLogic.cs new file mode 100644 index 00000000..23175e09 --- /dev/null +++ b/Sample/EventsVersioning/Talk/HotelManagement/GuestStayAccounts/ApplicationLogic.cs @@ -0,0 +1,35 @@ +using HotelManagement.EventStore; + +namespace HotelManagement.GuestStayAccounts; + +public class GuestStayAccountService(IEventStore eventStore) +{ + private readonly CommandHandler commandHandler = + new(GuestStayAccount.Evolve, () => GuestStayAccount.Initial); + + public Task CheckIn(CheckIn command, CancellationToken ct = default) + { + var guestStayAccountId = GuestStayAccount.GuestStayAccountId( + command.GuestStayId, command.RoomId, DateOnly.FromDateTime(command.Now.Date) + ); + + return commandHandler.Handle(eventStore, guestStayAccountId, + state => [GuestStayAccountDecider.CheckIn(command, state)], ct + ); + } + + public Task RecordCharge(RecordCharge command, CancellationToken ct = default) => + commandHandler.Handle(eventStore, command.GuestStayAccountId, + state => [GuestStayAccountDecider.RecordCharge(command, state)], ct + ); + + public Task RecordPayment(RecordPayment command, CancellationToken ct = default) => + commandHandler.Handle(eventStore, command.GuestStayAccountId, + state => [GuestStayAccountDecider.RecordPayment(command, state)], ct + ); + + public Task CheckOut(CheckOut command, CancellationToken ct = default) => + commandHandler.Handle(eventStore, command.GuestStayAccountId, + state => [GuestStayAccountDecider.CheckOut(command, state)], ct + ); +} diff --git a/Sample/EventsVersioning/Talk/HotelManagement/GuestStayAccounts/BusinessLogic.cs b/Sample/EventsVersioning/Talk/HotelManagement/GuestStayAccounts/BusinessLogic.cs new file mode 100644 index 00000000..4ff0a64a --- /dev/null +++ b/Sample/EventsVersioning/Talk/HotelManagement/GuestStayAccounts/BusinessLogic.cs @@ -0,0 +1,82 @@ +namespace HotelManagement.GuestStayAccounts; + +public record CheckIn( + string ClerkId, + string GuestStayId, + string RoomId, + DateTimeOffset Now +); + +public record RecordCharge( + string GuestStayAccountId, + decimal Amount, + DateTimeOffset Now +); + +public record RecordPayment( + string GuestStayAccountId, + decimal Amount, + DateTimeOffset Now +); + +public record CheckOut( + string ClerkId, + string GuestStayAccountId, + DateTimeOffset Now +); + +public static class GuestStayAccountDecider +{ + public static GuestCheckedIn CheckIn(CheckIn command, GuestStayAccount state) => + new GuestCheckedIn( + GuestStayAccount.GuestStayAccountId( + command.GuestStayId, + command.RoomId, + DateOnly.FromDateTime(command.Now.Date) + ), + command.GuestStayId, + command.RoomId, + command.ClerkId, + command.Now + ); + + public static ChargeRecorded RecordCharge(RecordCharge command, GuestStayAccount state) + { + if (state.Status != GuestStayAccountStatus.Opened) + throw new InvalidOperationException("Cannot record charge for not opened account"); + + return new ChargeRecorded(state.Id, command.Amount, command.Now); + } + + public static PaymentRecorded RecordPayment(RecordPayment command, GuestStayAccount state) + { + if (state.Status != GuestStayAccountStatus.Opened) + throw new InvalidOperationException("Cannot record charge for not opened account"); + + return new PaymentRecorded(state.Id, command.Amount, command.Now); + } + + public static object CheckOut(CheckOut command, GuestStayAccount state) + { + if (state.Status != GuestStayAccountStatus.Opened) + return new GuestCheckoutFailed( + state.Id, + command.ClerkId, + GuestCheckoutFailed.FailureReason.NotOpened, + command.Now + ); + + return state.IsSettled + ? new GuestCheckedOut( + state.Id, + command.ClerkId, + command.Now + ) + : new GuestCheckoutFailed( + state.Id, + command.ClerkId, + GuestCheckoutFailed.FailureReason.BalanceNotSettled, + command.Now + ); + } +} diff --git a/Sample/EventsVersioning/Talk/HotelManagement/GuestStayAccounts/GuestStayAccount.cs b/Sample/EventsVersioning/Talk/HotelManagement/GuestStayAccounts/GuestStayAccount.cs new file mode 100644 index 00000000..aaadd1c3 --- /dev/null +++ b/Sample/EventsVersioning/Talk/HotelManagement/GuestStayAccounts/GuestStayAccount.cs @@ -0,0 +1,76 @@ +namespace HotelManagement.GuestStayAccounts; + +public record GuestStayAccount( + string Id, + decimal Balance = 0, + GuestStayAccountStatus Status = GuestStayAccountStatus.Opened +) +{ + public bool IsSettled => Balance == 0; + + public static string GuestStayAccountId(string guestStayId, string roomId, DateOnly checkInDate) => + $"{guestStayId}:{roomId}:{checkInDate:yyyy-MM-dd}"; + + public static GuestStayAccount Evolve(GuestStayAccount state, object @event) => + @event switch + { + GuestCheckedIn checkedIn => state with + { + Id = checkedIn.GuestStayAccountId, Status = GuestStayAccountStatus.Opened + }, + ChargeRecorded charge => state with { Balance = state.Balance - charge.Amount }, + PaymentRecorded payment => state with { Balance = state.Balance + payment.Amount }, + GuestCheckedOut => state with { Status = GuestStayAccountStatus.CheckedOut }, + GuestCheckoutFailed => state, + _ => state + }; + + public static readonly GuestStayAccount Initial = new("Unknown", -1, GuestStayAccountStatus.NotExisting); +} + +public enum GuestStayAccountStatus +{ + NotExisting = 0, + Opened = 1, + CheckedOut = 2 +} + +public record GuestCheckedIn( + string GuestStayAccountId, + string GuestStayId, + string RoomId, + string ClerkId, + DateTimeOffset CheckedInAt +); + +public record ChargeRecorded( + string GuestStayAccountId, + decimal Amount, + DateTimeOffset RecordedAt +); + +public record PaymentRecorded( + string GuestStayAccountId, + decimal Amount, + DateTimeOffset RecordedAt +); + +public record GuestCheckedOut( + string GuestStayAccountId, + string ClerkId, + DateTimeOffset CheckedOutAt +); + +public record GuestCheckoutFailed( + string GuestStayAccountId, + string ClerkId, + GuestCheckoutFailed.FailureReason Reason, + DateTimeOffset FailedAt +) +{ + public enum FailureReason + { + NotOpened, + BalanceNotSettled + } +} diff --git a/Sample/EventsVersioning/Talk/HotelManagement/HotelManagement.csproj b/Sample/EventsVersioning/Talk/HotelManagement/HotelManagement.csproj new file mode 100644 index 00000000..3a635329 --- /dev/null +++ b/Sample/EventsVersioning/Talk/HotelManagement/HotelManagement.csproj @@ -0,0 +1,9 @@ + + + + net8.0 + enable + enable + + + diff --git a/Workshops/EventDrivenArchitecture/Solutions/05-Consistency/Sagas/Version1-Aggregates/Core/Retry.cs b/Workshops/EventDrivenArchitecture/Solutions/05-Consistency/Sagas/Version1-Aggregates/Core/Retry.cs index 35976e7c..6ebe8ca0 100644 --- a/Workshops/EventDrivenArchitecture/Solutions/05-Consistency/Sagas/Version1-Aggregates/Core/Retry.cs +++ b/Workshops/EventDrivenArchitecture/Solutions/05-Consistency/Sagas/Version1-Aggregates/Core/Retry.cs @@ -17,6 +17,6 @@ public class Retry return TimeSpan.FromMilliseconds(1); }); - public Task UntilSucceeds(Func handle, CancellationToken token = default) => + public static Task UntilSucceeds(Func handle, CancellationToken token = default) => retryPolicy.ExecuteAsync(async ct => await handle(ct), token); }