diff --git a/Workshops/IntroductionToEventSourcing/05-GettingStateFromEvents.Marten/Tools/MartenTest.cs b/Workshops/IntroductionToEventSourcing/05-GettingStateFromEvents.Marten/Tools/MartenTest.cs index 972f996cf..8117ce379 100644 --- a/Workshops/IntroductionToEventSourcing/05-GettingStateFromEvents.Marten/Tools/MartenTest.cs +++ b/Workshops/IntroductionToEventSourcing/05-GettingStateFromEvents.Marten/Tools/MartenTest.cs @@ -12,7 +12,7 @@ protected MartenTest() var options = new StoreOptions(); options.Connection( "PORT = 5432; HOST = localhost; TIMEOUT = 15; POOLING = True; DATABASE = 'postgres'; PASSWORD = 'Password12!'; USER ID = 'postgres'"); - options.UseDefaultSerialization(nonPublicMembersStorage: NonPublicMembersStorage.All); + options.UseNewtonsoftForSerialization(nonPublicMembersStorage: NonPublicMembersStorage.All); options.DatabaseSchemaName = options.Events.DatabaseSchemaName = "IntroductionToEventSourcing"; documentStore = new DocumentStore(options); diff --git a/Workshops/IntroductionToEventSourcing/08-ApplicationLogic.Marten.Tests/Incidents/AddProductItemToShoppingCartTests.cs b/Workshops/IntroductionToEventSourcing/08-ApplicationLogic.Marten.Tests/Incidents/AddProductItemToShoppingCartTests.cs index 2693b9ae4..c3b98137f 100644 --- a/Workshops/IntroductionToEventSourcing/08-ApplicationLogic.Marten.Tests/Incidents/AddProductItemToShoppingCartTests.cs +++ b/Workshops/IntroductionToEventSourcing/08-ApplicationLogic.Marten.Tests/Incidents/AddProductItemToShoppingCartTests.cs @@ -12,7 +12,9 @@ namespace ApplicationLogic.Marten.Tests.Incidents; public class AddProductItemToShoppingCartTests(ApiSpecification api): IClassFixture> { + [Theory] + [Trait("Category", "SkipCI")] [InlineData("immutable")] [InlineData("mutable")] [InlineData("mixed")] @@ -25,7 +27,9 @@ public Task CantAddProductItemToNotExistingShoppingCart(string apiPrefix) => ) .Then(NOT_FOUND); + [Theory] + [Trait("Category", "SkipCI")] [InlineData("immutable")] [InlineData("mutable")] [InlineData("mixed")] @@ -38,7 +42,9 @@ public Task AddsProductItemToEmptyShoppingCart(string apiPrefix) => ) .Then(NO_CONTENT); + [Theory] + [Trait("Category", "SkipCI")] [InlineData("immutable")] [InlineData("mutable")] [InlineData("mixed")] @@ -54,7 +60,9 @@ public Task AddsProductItemToNonEmptyShoppingCart(string apiPrefix) => ) .Then(NO_CONTENT); + [Theory] + [Trait("Category", "SkipCI")] [InlineData("immutable")] [InlineData("mutable")] [InlineData("mixed")] @@ -71,7 +79,9 @@ public Task CantAddProductItemToConfirmedShoppingCart(string apiPrefix) => ) .Then(CONFLICT); + [Theory] + [Trait("Category", "SkipCI")] [InlineData("immutable")] [InlineData("mutable")] [InlineData("mixed")] @@ -88,7 +98,9 @@ public Task CantAddProductItemToCanceledShoppingCart(string apiPrefix) => ) .Then(CONFLICT); + [Theory] + [Trait("Category", "SkipCI")] [InlineData("immutable")] [InlineData("mutable")] [InlineData("mixed")] diff --git a/Workshops/IntroductionToEventSourcing/08-ApplicationLogic.Marten.Tests/Incidents/CancelShoppingCartTests.cs b/Workshops/IntroductionToEventSourcing/08-ApplicationLogic.Marten.Tests/Incidents/CancelShoppingCartTests.cs index 8224ded90..ccdb8c5aa 100644 --- a/Workshops/IntroductionToEventSourcing/08-ApplicationLogic.Marten.Tests/Incidents/CancelShoppingCartTests.cs +++ b/Workshops/IntroductionToEventSourcing/08-ApplicationLogic.Marten.Tests/Incidents/CancelShoppingCartTests.cs @@ -12,7 +12,9 @@ namespace ApplicationLogic.Marten.Tests.Incidents; public class CancelShoppingCartTests(ApiSpecification api): IClassFixture> { + [Theory] + [Trait("Category", "SkipCI")] [InlineData("immutable")] [InlineData("mutable")] [InlineData("mixed")] @@ -24,7 +26,9 @@ public Task CantCancelNotExistingShoppingCart(string apiPrefix) => ) .Then(NOT_FOUND); + [Theory] + [Trait("Category", "SkipCI")] [InlineData("immutable")] [InlineData("mutable")] [InlineData("mixed")] @@ -39,7 +43,9 @@ public Task CancelsNonEmptyShoppingCart(string apiPrefix) => ) .Then(NO_CONTENT); + [Theory] + [Trait("Category", "SkipCI")] [InlineData("immutable")] [InlineData("mutable")] [InlineData("mixed")] @@ -55,7 +61,9 @@ public Task CantCancelAlreadyCanceledShoppingCart(string apiPrefix) => ) .Then(CONFLICT); + [Theory] + [Trait("Category", "SkipCI")] [InlineData("immutable")] [InlineData("mutable")] [InlineData("mixed")] @@ -71,7 +79,9 @@ public Task CantCancelConfirmedShoppingCart(string apiPrefix) => ) .Then(CONFLICT); + [Theory] + [Trait("Category", "SkipCI")] [InlineData("immutable")] [InlineData("mutable")] [InlineData("mixed")] diff --git a/Workshops/IntroductionToEventSourcing/08-ApplicationLogic.Marten.Tests/Incidents/ConfirmShoppingCartTests.cs b/Workshops/IntroductionToEventSourcing/08-ApplicationLogic.Marten.Tests/Incidents/ConfirmShoppingCartTests.cs index 1bcb97343..6c1d3cd89 100644 --- a/Workshops/IntroductionToEventSourcing/08-ApplicationLogic.Marten.Tests/Incidents/ConfirmShoppingCartTests.cs +++ b/Workshops/IntroductionToEventSourcing/08-ApplicationLogic.Marten.Tests/Incidents/ConfirmShoppingCartTests.cs @@ -12,7 +12,9 @@ namespace ApplicationLogic.Marten.Tests.Incidents; public class ConfirmShoppingCartTests(ApiSpecification api): IClassFixture> { + [Theory] + [Trait("Category", "SkipCI")] [InlineData("immutable")] [InlineData("mutable")] [InlineData("mixed")] @@ -24,7 +26,9 @@ public Task CantConfirmNotExistingShoppingCart(string apiPrefix) => ) .Then(NOT_FOUND); + [Theory] + [Trait("Category", "SkipCI")] [InlineData("immutable")] [InlineData("mutable")] [InlineData("mixed")] @@ -36,7 +40,9 @@ public Task CantConfirmEmptyShoppingCart(string apiPrefix) => ) .Then(CONFLICT); + [Theory] + [Trait("Category", "SkipCI")] [InlineData("immutable")] [InlineData("mutable")] [InlineData("mixed")] @@ -51,7 +57,9 @@ public Task ConfirmsNonEmptyShoppingCart(string apiPrefix) => ) .Then(NO_CONTENT); + [Theory] + [Trait("Category", "SkipCI")] [InlineData("immutable")] [InlineData("mutable")] [InlineData("mixed")] @@ -67,7 +75,9 @@ public Task CantConfirmAlreadyConfirmedShoppingCart(string apiPrefix) => ) .Then(CONFLICT); + [Theory] + [Trait("Category", "SkipCI")] [InlineData("immutable")] [InlineData("mutable")] [InlineData("mixed")] @@ -83,7 +93,9 @@ public Task CantConfirmCanceledShoppingCart(string apiPrefix) => ) .Then(CONFLICT); + [Theory] + [Trait("Category", "SkipCI")] [InlineData("immutable")] [InlineData("mutable")] [InlineData("mixed")] diff --git a/Workshops/IntroductionToEventSourcing/08-ApplicationLogic.Marten.Tests/Incidents/OpenShoppingCartTests.cs b/Workshops/IntroductionToEventSourcing/08-ApplicationLogic.Marten.Tests/Incidents/OpenShoppingCartTests.cs index fb8f7778a..71b63dde2 100644 --- a/Workshops/IntroductionToEventSourcing/08-ApplicationLogic.Marten.Tests/Incidents/OpenShoppingCartTests.cs +++ b/Workshops/IntroductionToEventSourcing/08-ApplicationLogic.Marten.Tests/Incidents/OpenShoppingCartTests.cs @@ -9,7 +9,9 @@ namespace ApplicationLogic.Marten.Tests.Incidents; public class OpenShoppingCartTests(ApiSpecification api): IClassFixture> { + [Theory] + [Trait("Category", "SkipCI")] [InlineData("immutable")] [InlineData("mutable")] [InlineData("mixed")] @@ -18,7 +20,9 @@ public Task OpensShoppingCart(string apiPrefix) => .When(POST, URI(ShoppingCarts(apiPrefix, ClientId))) .Then(CREATED_WITH_DEFAULT_HEADERS(locationHeaderPrefix: ShoppingCarts(apiPrefix, ClientId))); + [Theory] + [Trait("Category", "SkipCI")] [InlineData("immutable")] [InlineData("mutable")] [InlineData("mixed")] diff --git a/Workshops/IntroductionToEventSourcing/08-ApplicationLogic.Marten.Tests/Incidents/RemoveProductItemFromShoppingCartTests.cs b/Workshops/IntroductionToEventSourcing/08-ApplicationLogic.Marten.Tests/Incidents/RemoveProductItemFromShoppingCartTests.cs index 6b1503465..094998016 100644 --- a/Workshops/IntroductionToEventSourcing/08-ApplicationLogic.Marten.Tests/Incidents/RemoveProductItemFromShoppingCartTests.cs +++ b/Workshops/IntroductionToEventSourcing/08-ApplicationLogic.Marten.Tests/Incidents/RemoveProductItemFromShoppingCartTests.cs @@ -12,7 +12,9 @@ namespace ApplicationLogic.Marten.Tests.Incidents; public class RemoveProductItemFromShoppingCartTests(ApiSpecification api): IClassFixture> { + [Theory] + [Trait("Category", "SkipCI")] [InlineData("immutable")] [InlineData("mutable")] [InlineData("mixed")] @@ -24,7 +26,9 @@ public Task CantRemoveProductItemFromNotExistingShoppingCart(string apiPrefix) = ) .Then(NOT_FOUND); + [Theory] + [Trait("Category", "SkipCI")] [InlineData("immutable")] [InlineData("mutable")] [InlineData("mixed")] @@ -36,7 +40,9 @@ public Task CantRemoveProductItemFromEmptyShoppingCart(string apiPrefix) => ) .Then(CONFLICT); + [Theory] + [Trait("Category", "SkipCI")] [InlineData("immutable")] [InlineData("mutable")] [InlineData("mixed")] @@ -51,7 +57,9 @@ public Task CanRemoveExistingProductItemFromShoppingCart(string apiPrefix) => ) .Then(NO_CONTENT); + [Theory] + [Trait("Category", "SkipCI")] [InlineData("immutable")] [InlineData("mutable")] [InlineData("mixed")] @@ -66,7 +74,9 @@ public Task CantRemoveNonExistingProductItemFromEmptyShoppingCart(string apiPref ) .Then(CONFLICT); + [Theory] + [Trait("Category", "SkipCI")] [InlineData("immutable")] [InlineData("mutable")] [InlineData("mixed")] @@ -82,7 +92,9 @@ public Task CantRemoveExistingProductItemFromCanceledShoppingCart(string apiPref ) .Then(CONFLICT); + [Theory] + [Trait("Category", "SkipCI")] [InlineData("immutable")] [InlineData("mutable")] [InlineData("mixed")] @@ -98,7 +110,9 @@ public Task CantRemoveExistingProductItemFromConfirmedShoppingCart(string apiPre ) .Then(CONFLICT); + [Theory] + [Trait("Category", "SkipCI")] [InlineData("immutable")] [InlineData("mutable")] [InlineData("mixed")] diff --git a/Workshops/IntroductionToEventSourcing/08-ApplicationLogic.Marten/Core/Marten/DocumentSessionExtensions.cs b/Workshops/IntroductionToEventSourcing/08-ApplicationLogic.Marten/Core/Marten/DocumentSessionExtensions.cs index d51da9ae2..f5369263c 100644 --- a/Workshops/IntroductionToEventSourcing/08-ApplicationLogic.Marten/Core/Marten/DocumentSessionExtensions.cs +++ b/Workshops/IntroductionToEventSourcing/08-ApplicationLogic.Marten/Core/Marten/DocumentSessionExtensions.cs @@ -1,5 +1,4 @@ using ApplicationLogic.Marten.Core.Entities; -using ApplicationLogic.Marten.Core.Exceptions; using Marten; namespace ApplicationLogic.Marten.Core.Marten; @@ -8,37 +7,23 @@ public static class DocumentSessionExtensions { public static Task Add(this IDocumentSession documentSession, Guid id, object @event, CancellationToken ct) where T : class - { - documentSession.Events.StartStream(id, @event); - return documentSession.SaveChangesAsync(token: ct); - } + => throw new NotImplementedException("Document Session Extensions not implemented!"); public static Task Add(this IDocumentSession documentSession, Guid id, object[] events, CancellationToken ct) where T : class - { - documentSession.Events.StartStream(id, events); - return documentSession.SaveChangesAsync(token: ct); - } + => throw new NotImplementedException("Document Session Extensions not implemented!"); public static Task GetAndUpdate( this IDocumentSession documentSession, Guid id, Func handle, CancellationToken ct - ) where T : class => - documentSession.Events.WriteToAggregate(id, stream => - stream.AppendMany(handle(stream.Aggregate ?? throw NotFoundException.For(id))), ct); + ) where T : class => throw new NotImplementedException("Document Session Extensions not implemented!"); public static Task GetAndUpdate( this IDocumentSession documentSession, Guid id, Action handle, CancellationToken ct - ) where T : class, IAggregate => - documentSession.Events.WriteToAggregate(id, stream => - { - var aggregate = stream.Aggregate ?? throw NotFoundException.For(id); - handle(aggregate); - stream.AppendMany(aggregate.DequeueUncommittedEvents()); - }, ct); + ) where T : class, IAggregate => throw new NotImplementedException("Document Session Extensions not implemented!"); } diff --git a/Workshops/IntroductionToEventSourcing/08-ApplicationLogic.Marten/Program.cs b/Workshops/IntroductionToEventSourcing/08-ApplicationLogic.Marten/Program.cs index 949cb73fb..ecb38b84c 100644 --- a/Workshops/IntroductionToEventSourcing/08-ApplicationLogic.Marten/Program.cs +++ b/Workshops/IntroductionToEventSourcing/08-ApplicationLogic.Marten/Program.cs @@ -17,7 +17,7 @@ .AddImmutableShoppingCarts() .AddMarten(options => { - var schemaName = Environment.GetEnvironmentVariable("SchemaName") ?? "Workshop_ShoppingCarts"; + var schemaName = Environment.GetEnvironmentVariable("SchemaName") ?? "Workshop_Application_ShoppingCarts"; options.Events.DatabaseSchemaName = schemaName; options.DatabaseSchemaName = schemaName; options.Connection(builder.Configuration.GetConnectionString("ShoppingCarts") ?? diff --git a/Workshops/IntroductionToEventSourcing/10-OptimisticConcurrency.Marten.Tests/10-OptimisticConcurrency.Marten.Tests.csproj b/Workshops/IntroductionToEventSourcing/10-OptimisticConcurrency.Marten.Tests/10-OptimisticConcurrency.Marten.Tests.csproj new file mode 100644 index 000000000..6a1f4ff9e --- /dev/null +++ b/Workshops/IntroductionToEventSourcing/10-OptimisticConcurrency.Marten.Tests/10-OptimisticConcurrency.Marten.Tests.csproj @@ -0,0 +1,32 @@ + + + + net8.0 + ApplicationLogic.Marten.Tests + ApplicationLogic.Marten.Tests + + + + + + + + + + + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + + + + + + + diff --git a/Workshops/IntroductionToEventSourcing/10-OptimisticConcurrency.Marten.Tests/Incidents/AddProductItemToShoppingCartTests.cs b/Workshops/IntroductionToEventSourcing/10-OptimisticConcurrency.Marten.Tests/Incidents/AddProductItemToShoppingCartTests.cs new file mode 100644 index 000000000..2693b9ae4 --- /dev/null +++ b/Workshops/IntroductionToEventSourcing/10-OptimisticConcurrency.Marten.Tests/Incidents/AddProductItemToShoppingCartTests.cs @@ -0,0 +1,107 @@ +using System.Net; +using ApplicationLogic.Marten.Immutable.ShoppingCarts; +using Bogus; +using Ogooreck.API; +using Xunit; +using static Ogooreck.API.ApiSpecification; +using static ApplicationLogic.Marten.Tests.Incidents.Scenarios; +using static ApplicationLogic.Marten.Tests.Incidents.Fixtures; + +namespace ApplicationLogic.Marten.Tests.Incidents; + +public class AddProductItemToShoppingCartTests(ApiSpecification api): + IClassFixture> +{ + [Theory] + [InlineData("immutable")] + [InlineData("mutable")] + [InlineData("mixed")] + public Task CantAddProductItemToNotExistingShoppingCart(string apiPrefix) => + api.Given() + .When( + POST, + URI(ShoppingCartProductItems(apiPrefix, ClientId, NotExistingShoppingCartId)), + BODY(new AddProductRequest(ProductItem)) + ) + .Then(NOT_FOUND); + + [Theory] + [InlineData("immutable")] + [InlineData("mutable")] + [InlineData("mixed")] + public Task AddsProductItemToEmptyShoppingCart(string apiPrefix) => + api.Given(OpenedShoppingCart(apiPrefix, ClientId)) + .When( + POST, + URI(ctx => ShoppingCartProductItems(apiPrefix, ClientId, ctx.GetCreatedId())), + BODY(new AddProductRequest(ProductItem)) + ) + .Then(NO_CONTENT); + + [Theory] + [InlineData("immutable")] + [InlineData("mutable")] + [InlineData("mixed")] + public Task AddsProductItemToNonEmptyShoppingCart(string apiPrefix) => + api.Given( + OpenedShoppingCart(apiPrefix, ClientId), + WithProductItem(apiPrefix, ClientId, ProductItem) + ) + .When( + POST, + URI(ctx => ShoppingCartProductItems(apiPrefix, ClientId, ctx.GetCreatedId())), + BODY(new AddProductRequest(ProductItem)) + ) + .Then(NO_CONTENT); + + [Theory] + [InlineData("immutable")] + [InlineData("mutable")] + [InlineData("mixed")] + public Task CantAddProductItemToConfirmedShoppingCart(string apiPrefix) => + api.Given( + OpenedShoppingCart(apiPrefix, ClientId), + WithProductItem(apiPrefix, ClientId, ProductItem), + ThenConfirmed(apiPrefix, ClientId) + ) + .When( + POST, + URI(ctx => ShoppingCartProductItems(apiPrefix, ClientId, ctx.GetCreatedId())), + BODY(new AddProductRequest(ProductItem)) + ) + .Then(CONFLICT); + + [Theory] + [InlineData("immutable")] + [InlineData("mutable")] + [InlineData("mixed")] + public Task CantAddProductItemToCanceledShoppingCart(string apiPrefix) => + api.Given( + OpenedShoppingCart(apiPrefix, ClientId), + WithProductItem(apiPrefix, ClientId, ProductItem), + ThenCanceled(apiPrefix, ClientId) + ) + .When( + POST, + URI(ctx => ShoppingCartProductItems(apiPrefix, ClientId, ctx.GetCreatedId())), + BODY(new AddProductRequest(ProductItem)) + ) + .Then(CONFLICT); + + [Theory] + [InlineData("immutable")] + [InlineData("mutable")] + [InlineData("mixed")] + public Task ReturnsNonEmptyShoppingCart(string apiPrefix) => + api.Given( + OpenedShoppingCart(apiPrefix, ClientId), + WithProductItem(apiPrefix, ClientId, ProductItem) + ) + .When(GET, URI(ctx => ShoppingCart(apiPrefix, ClientId, ctx.GetCreatedId()))) + .Then(OK); + + private static readonly Faker Faker = new(); + private readonly Guid NotExistingShoppingCartId = Guid.NewGuid(); + private readonly Guid ClientId = Guid.NewGuid(); + private readonly ProductItemRequest ProductItem = new(Guid.NewGuid(), Faker.Random.Number(1, 500)); +} diff --git a/Workshops/IntroductionToEventSourcing/10-OptimisticConcurrency.Marten.Tests/Incidents/CancelShoppingCartTests.cs b/Workshops/IntroductionToEventSourcing/10-OptimisticConcurrency.Marten.Tests/Incidents/CancelShoppingCartTests.cs new file mode 100644 index 000000000..8224ded90 --- /dev/null +++ b/Workshops/IntroductionToEventSourcing/10-OptimisticConcurrency.Marten.Tests/Incidents/CancelShoppingCartTests.cs @@ -0,0 +1,91 @@ +using System.Net; +using ApplicationLogic.Marten.Immutable.ShoppingCarts; +using Bogus; +using Ogooreck.API; +using Xunit; +using static Ogooreck.API.ApiSpecification; +using static ApplicationLogic.Marten.Tests.Incidents.Scenarios; +using static ApplicationLogic.Marten.Tests.Incidents.Fixtures; + +namespace ApplicationLogic.Marten.Tests.Incidents; + +public class CancelShoppingCartTests(ApiSpecification api): + IClassFixture> +{ + [Theory] + [InlineData("immutable")] + [InlineData("mutable")] + [InlineData("mixed")] + public Task CantCancelNotExistingShoppingCart(string apiPrefix) => + api.Given() + .When( + DELETE, + URI(ShoppingCart(apiPrefix, ClientId, NotExistingShoppingCartId)) + ) + .Then(NOT_FOUND); + + [Theory] + [InlineData("immutable")] + [InlineData("mutable")] + [InlineData("mixed")] + public Task CancelsNonEmptyShoppingCart(string apiPrefix) => + api.Given( + OpenedShoppingCart(apiPrefix, ClientId), + WithProductItem(apiPrefix, ClientId, ProductItem) + ) + .When( + DELETE, + URI(ctx => ShoppingCart(apiPrefix, ClientId, ctx.GetCreatedId())) + ) + .Then(NO_CONTENT); + + [Theory] + [InlineData("immutable")] + [InlineData("mutable")] + [InlineData("mixed")] + public Task CantCancelAlreadyCanceledShoppingCart(string apiPrefix) => + api.Given( + OpenedShoppingCart(apiPrefix, ClientId), + WithProductItem(apiPrefix, ClientId, ProductItem), + ThenCanceled(apiPrefix, ClientId) + ) + .When( + DELETE, + URI(ctx => ShoppingCart(apiPrefix, ClientId, ctx.GetCreatedId())) + ) + .Then(CONFLICT); + + [Theory] + [InlineData("immutable")] + [InlineData("mutable")] + [InlineData("mixed")] + public Task CantCancelConfirmedShoppingCart(string apiPrefix) => + api.Given( + OpenedShoppingCart(apiPrefix, ClientId), + WithProductItem(apiPrefix, ClientId, ProductItem), + ThenConfirmed(apiPrefix, ClientId) + ) + .When( + DELETE, + URI(ctx => ShoppingCart(apiPrefix, ClientId, ctx.GetCreatedId())) + ) + .Then(CONFLICT); + + [Theory] + [InlineData("immutable")] + [InlineData("mutable")] + [InlineData("mixed")] + public Task ReturnsNonEmptyShoppingCart(string apiPrefix) => + api.Given( + OpenedShoppingCart(apiPrefix, ClientId), + WithProductItem(apiPrefix, ClientId, ProductItem), + ThenCanceled(apiPrefix, ClientId) + ) + .When(GET, URI(ctx => ShoppingCart(apiPrefix, ClientId, ctx.GetCreatedId()))) + .Then(OK); + + private static readonly Faker Faker = new(); + private readonly Guid NotExistingShoppingCartId = Guid.NewGuid(); + private readonly Guid ClientId = Guid.NewGuid(); + private readonly ProductItemRequest ProductItem = new(Guid.NewGuid(), Faker.Random.Number(1, 500)); +} diff --git a/Workshops/IntroductionToEventSourcing/10-OptimisticConcurrency.Marten.Tests/Incidents/ConfirmShoppingCartTests.cs b/Workshops/IntroductionToEventSourcing/10-OptimisticConcurrency.Marten.Tests/Incidents/ConfirmShoppingCartTests.cs new file mode 100644 index 000000000..1bcb97343 --- /dev/null +++ b/Workshops/IntroductionToEventSourcing/10-OptimisticConcurrency.Marten.Tests/Incidents/ConfirmShoppingCartTests.cs @@ -0,0 +1,103 @@ +using System.Net; +using ApplicationLogic.Marten.Immutable.ShoppingCarts; +using Bogus; +using Ogooreck.API; +using Xunit; +using static Ogooreck.API.ApiSpecification; +using static ApplicationLogic.Marten.Tests.Incidents.Scenarios; +using static ApplicationLogic.Marten.Tests.Incidents.Fixtures; + +namespace ApplicationLogic.Marten.Tests.Incidents; + +public class ConfirmShoppingCartTests(ApiSpecification api): + IClassFixture> +{ + [Theory] + [InlineData("immutable")] + [InlineData("mutable")] + [InlineData("mixed")] + public Task CantConfirmNotExistingShoppingCart(string apiPrefix) => + api.Given() + .When( + POST, + URI(ConfirmShoppingCart(apiPrefix, ClientId, NotExistingShoppingCartId)) + ) + .Then(NOT_FOUND); + + [Theory] + [InlineData("immutable")] + [InlineData("mutable")] + [InlineData("mixed")] + public Task CantConfirmEmptyShoppingCart(string apiPrefix) => + api.Given(OpenedShoppingCart(apiPrefix, ClientId)) + .When( + POST, + URI(ctx => ConfirmShoppingCart(apiPrefix, ClientId, ctx.GetCreatedId())) + ) + .Then(CONFLICT); + + [Theory] + [InlineData("immutable")] + [InlineData("mutable")] + [InlineData("mixed")] + public Task ConfirmsNonEmptyShoppingCart(string apiPrefix) => + api.Given( + OpenedShoppingCart(apiPrefix, ClientId), + WithProductItem(apiPrefix, ClientId, ProductItem) + ) + .When( + POST, + URI(ctx => ConfirmShoppingCart(apiPrefix, ClientId, ctx.GetCreatedId())) + ) + .Then(NO_CONTENT); + + [Theory] + [InlineData("immutable")] + [InlineData("mutable")] + [InlineData("mixed")] + public Task CantConfirmAlreadyConfirmedShoppingCart(string apiPrefix) => + api.Given( + OpenedShoppingCart(apiPrefix, ClientId), + WithProductItem(apiPrefix, ClientId, ProductItem), + ThenConfirmed(apiPrefix, ClientId) + ) + .When( + POST, + URI(ctx => ConfirmShoppingCart(apiPrefix, ClientId, ctx.GetCreatedId())) + ) + .Then(CONFLICT); + + [Theory] + [InlineData("immutable")] + [InlineData("mutable")] + [InlineData("mixed")] + public Task CantConfirmCanceledShoppingCart(string apiPrefix) => + api.Given( + OpenedShoppingCart(apiPrefix, ClientId), + WithProductItem(apiPrefix, ClientId, ProductItem), + ThenCanceled(apiPrefix, ClientId) + ) + .When( + POST, + URI(ctx => ConfirmShoppingCart(apiPrefix, ClientId, ctx.GetCreatedId())) + ) + .Then(CONFLICT); + + [Theory] + [InlineData("immutable")] + [InlineData("mutable")] + [InlineData("mixed")] + public Task ReturnsNonEmptyShoppingCart(string apiPrefix) => + api.Given( + OpenedShoppingCart(apiPrefix, ClientId), + WithProductItem(apiPrefix, ClientId, ProductItem), + ThenConfirmed(apiPrefix, ClientId) + ) + .When(GET, URI(ctx => ShoppingCart(apiPrefix, ClientId, ctx.GetCreatedId()))) + .Then(OK); + + private static readonly Faker Faker = new(); + private readonly Guid NotExistingShoppingCartId = Guid.NewGuid(); + private readonly Guid ClientId = Guid.NewGuid(); + private readonly ProductItemRequest ProductItem = new(Guid.NewGuid(), Faker.Random.Number(1, 500)); +} diff --git a/Workshops/IntroductionToEventSourcing/10-OptimisticConcurrency.Marten.Tests/Incidents/Fixtures.cs b/Workshops/IntroductionToEventSourcing/10-OptimisticConcurrency.Marten.Tests/Incidents/Fixtures.cs new file mode 100644 index 000000000..f15700bcc --- /dev/null +++ b/Workshops/IntroductionToEventSourcing/10-OptimisticConcurrency.Marten.Tests/Incidents/Fixtures.cs @@ -0,0 +1,51 @@ +using ApplicationLogic.Marten.Immutable.ShoppingCarts; +using Ogooreck.API; +using static Ogooreck.API.ApiSpecification; + +namespace ApplicationLogic.Marten.Tests.Incidents; + +public static class Fixtures +{ + public static string Clients(string apiPrefix, Guid clientId) => $"/api/{apiPrefix}/clients/{clientId}/"; + + public static string ShoppingCarts(string apiPrefix, Guid clientId) => + $"{Clients(apiPrefix, clientId)}shopping-carts/"; + + public static string ShoppingCart(string apiPrefix, Guid clientId, Guid shoppingCartId) => + $"{ShoppingCarts(apiPrefix, clientId)}{shoppingCartId}/"; + + public static string ConfirmShoppingCart(string apiPrefix, Guid clientId, Guid shoppingCartId) => + $"{ShoppingCart(apiPrefix, clientId, shoppingCartId)}confirm"; + + public static string ShoppingCartProductItems(string apiPrefix, Guid clientId, Guid shoppingCartId) => + $"{ShoppingCart(apiPrefix, clientId, shoppingCartId)}product-items/"; + + public static string ShoppingCartProductItem(string apiPrefix, Guid clientId, Guid shoppingCartId, Guid productId, + int quantity = 1, decimal unitPrice = 100) => + $"{ShoppingCartProductItems(apiPrefix, clientId, shoppingCartId)}{productId}?unitPrice={unitPrice}&quantity={quantity}"; +} + +public static class Scenarios +{ + public static RequestDefinition OpenedShoppingCart(string apiPrefix, Guid clientId) => + SEND(POST, URI(Fixtures.ShoppingCarts(apiPrefix, clientId))); + + public static RequestDefinition WithProductItem(string apiPrefix, Guid clientId, ProductItemRequest productItem) => + SEND( + POST, + URI(ctx => Fixtures.ShoppingCartProductItems(apiPrefix, clientId, ctx.GetCreatedId())), + BODY(new AddProductRequest(productItem)) + ); + + public static RequestDefinition ThenConfirmed(string apiPrefix, Guid clientId) => + SEND( + POST, + URI(ctx => Fixtures.ConfirmShoppingCart(apiPrefix, clientId, ctx.GetCreatedId())) + ); + + public static RequestDefinition ThenCanceled(string apiPrefix, Guid clientId) => + SEND( + DELETE, + URI(ctx => Fixtures.ShoppingCart(apiPrefix, clientId, ctx.GetCreatedId())) + ); +} diff --git a/Workshops/IntroductionToEventSourcing/10-OptimisticConcurrency.Marten.Tests/Incidents/OpenShoppingCartTests.cs b/Workshops/IntroductionToEventSourcing/10-OptimisticConcurrency.Marten.Tests/Incidents/OpenShoppingCartTests.cs new file mode 100644 index 000000000..fb8f7778a --- /dev/null +++ b/Workshops/IntroductionToEventSourcing/10-OptimisticConcurrency.Marten.Tests/Incidents/OpenShoppingCartTests.cs @@ -0,0 +1,31 @@ +using Ogooreck.API; +using Xunit; +using static Ogooreck.API.ApiSpecification; +using static ApplicationLogic.Marten.Tests.Incidents.Scenarios; +using static ApplicationLogic.Marten.Tests.Incidents.Fixtures; + +namespace ApplicationLogic.Marten.Tests.Incidents; + +public class OpenShoppingCartTests(ApiSpecification api): + IClassFixture> +{ + [Theory] + [InlineData("immutable")] + [InlineData("mutable")] + [InlineData("mixed")] + public Task OpensShoppingCart(string apiPrefix) => + api.Given() + .When(POST, URI(ShoppingCarts(apiPrefix, ClientId))) + .Then(CREATED_WITH_DEFAULT_HEADERS(locationHeaderPrefix: ShoppingCarts(apiPrefix, ClientId))); + + [Theory] + [InlineData("immutable")] + [InlineData("mutable")] + [InlineData("mixed")] + public Task ReturnsOpenedShoppingCart(string apiPrefix) => + api.Given(OpenedShoppingCart(apiPrefix, ClientId)) + .When(GET, URI(ctx => ShoppingCart(apiPrefix, ClientId, ctx.GetCreatedId()))) + .Then(OK); + + private readonly Guid ClientId = Guid.NewGuid(); +} diff --git a/Workshops/IntroductionToEventSourcing/10-OptimisticConcurrency.Marten.Tests/Incidents/RemoveProductItemFromShoppingCartTests.cs b/Workshops/IntroductionToEventSourcing/10-OptimisticConcurrency.Marten.Tests/Incidents/RemoveProductItemFromShoppingCartTests.cs new file mode 100644 index 000000000..6b1503465 --- /dev/null +++ b/Workshops/IntroductionToEventSourcing/10-OptimisticConcurrency.Marten.Tests/Incidents/RemoveProductItemFromShoppingCartTests.cs @@ -0,0 +1,118 @@ +using System.Net; +using ApplicationLogic.Marten.Immutable.ShoppingCarts; +using Bogus; +using Ogooreck.API; +using Xunit; +using static Ogooreck.API.ApiSpecification; +using static ApplicationLogic.Marten.Tests.Incidents.Scenarios; +using static ApplicationLogic.Marten.Tests.Incidents.Fixtures; + +namespace ApplicationLogic.Marten.Tests.Incidents; + +public class RemoveProductItemFromShoppingCartTests(ApiSpecification api): + IClassFixture> +{ + [Theory] + [InlineData("immutable")] + [InlineData("mutable")] + [InlineData("mixed")] + public Task CantRemoveProductItemFromNotExistingShoppingCart(string apiPrefix) => + api.Given() + .When( + DELETE, + URI(ShoppingCartProductItem(apiPrefix, ClientId, NotExistingShoppingCartId, ProductItem.ProductId!.Value)) + ) + .Then(NOT_FOUND); + + [Theory] + [InlineData("immutable")] + [InlineData("mutable")] + [InlineData("mixed")] + public Task CantRemoveProductItemFromEmptyShoppingCart(string apiPrefix) => + api.Given(OpenedShoppingCart(apiPrefix, ClientId)) + .When( + DELETE, + URI(ctx => ShoppingCartProductItem(apiPrefix, ClientId, ctx.GetCreatedId(), ProductItem.ProductId!.Value)) + ) + .Then(CONFLICT); + + [Theory] + [InlineData("immutable")] + [InlineData("mutable")] + [InlineData("mixed")] + public Task CanRemoveExistingProductItemFromShoppingCart(string apiPrefix) => + api.Given( + OpenedShoppingCart(apiPrefix, ClientId), + WithProductItem(apiPrefix, ClientId, ProductItem) + ) + .When( + DELETE, + URI(ctx => ShoppingCartProductItem(apiPrefix, ClientId, ctx.GetCreatedId(), ProductItem.ProductId!.Value)) + ) + .Then(NO_CONTENT); + + [Theory] + [InlineData("immutable")] + [InlineData("mutable")] + [InlineData("mixed")] + public Task CantRemoveNonExistingProductItemFromEmptyShoppingCart(string apiPrefix) => + api.Given( + OpenedShoppingCart(apiPrefix, ClientId), + WithProductItem(apiPrefix, ClientId, ProductItem) + ) + .When( + DELETE, + URI(ctx => ShoppingCartProductItem(apiPrefix, ClientId, ctx.GetCreatedId(), NotExistingProductItem.ProductId!.Value)) + ) + .Then(CONFLICT); + + [Theory] + [InlineData("immutable")] + [InlineData("mutable")] + [InlineData("mixed")] + public Task CantRemoveExistingProductItemFromCanceledShoppingCart(string apiPrefix) => + api.Given( + OpenedShoppingCart(apiPrefix, ClientId), + WithProductItem(apiPrefix, ClientId, ProductItem), + ThenCanceled(apiPrefix, ClientId) + ) + .When( + DELETE, + URI(ctx => ShoppingCartProductItem(apiPrefix, ClientId, ctx.GetCreatedId(), ProductItem.ProductId!.Value)) + ) + .Then(CONFLICT); + + [Theory] + [InlineData("immutable")] + [InlineData("mutable")] + [InlineData("mixed")] + public Task CantRemoveExistingProductItemFromConfirmedShoppingCart(string apiPrefix) => + api.Given( + OpenedShoppingCart(apiPrefix, ClientId), + WithProductItem(apiPrefix, ClientId, ProductItem), + ThenConfirmed(apiPrefix, ClientId) + ) + .When( + DELETE, + URI(ctx => ShoppingCartProductItem(apiPrefix, ClientId, ctx.GetCreatedId(), ProductItem.ProductId!.Value)) + ) + .Then(CONFLICT); + + [Theory] + [InlineData("immutable")] + [InlineData("mutable")] + [InlineData("mixed")] + public Task ReturnsNonEmptyShoppingCart(string apiPrefix) => + api.Given( + OpenedShoppingCart(apiPrefix, ClientId), + WithProductItem(apiPrefix, ClientId, ProductItem) + ) + .When(GET, URI(ctx => ShoppingCart(apiPrefix, ClientId, ctx.GetCreatedId()))) + .Then(OK); + + private static readonly Faker Faker = new(); + private readonly Guid NotExistingShoppingCartId = Guid.NewGuid(); + private readonly Guid ClientId = Guid.NewGuid(); + private readonly ProductItemRequest ProductItem = new(Guid.NewGuid(), Faker.Random.Number(1, 500)); + private readonly ProductItemRequest NotExistingProductItem = new(Guid.NewGuid(), 1); +} diff --git a/Workshops/IntroductionToEventSourcing/10-OptimisticConcurrency.Marten.Tests/Settings.cs b/Workshops/IntroductionToEventSourcing/10-OptimisticConcurrency.Marten.Tests/Settings.cs new file mode 100644 index 000000000..b95fadcec --- /dev/null +++ b/Workshops/IntroductionToEventSourcing/10-OptimisticConcurrency.Marten.Tests/Settings.cs @@ -0,0 +1,17 @@ +using Oakton; +using Xunit; +using Xunit.Abstractions; +using Xunit.Sdk; + +[assembly: TestFramework("ApplicationLogic.Marten.Tests.AssemblyFixture", "ApplicationLogic.Marten.Tests")] + +namespace ApplicationLogic.Marten.Tests; + +public sealed class AssemblyFixture : XunitTestFramework +{ + public AssemblyFixture(IMessageSink messageSink) + :base(messageSink) + { + OaktonEnvironment.AutoStartHost = true; + } +} diff --git a/Workshops/IntroductionToEventSourcing/10-OptimisticConcurrency.Marten/10-OptimisticConcurrency.Marten.csproj b/Workshops/IntroductionToEventSourcing/10-OptimisticConcurrency.Marten/10-OptimisticConcurrency.Marten.csproj index 2e02ece76..9e9376a6e 100644 --- a/Workshops/IntroductionToEventSourcing/10-OptimisticConcurrency.Marten/10-OptimisticConcurrency.Marten.csproj +++ b/Workshops/IntroductionToEventSourcing/10-OptimisticConcurrency.Marten/10-OptimisticConcurrency.Marten.csproj @@ -1,23 +1,21 @@ - + net8.0 - IntroductionToEventSourcing.OptimisticConcurrency + enable + true + enable + ApplicationLogic.Marten - + + + - - - - all - runtime; build; native; contentfiles; analyzers - - - runtime; build; native; contentfiles; analyzers; buildtransitive - all - + + + diff --git a/Workshops/IntroductionToEventSourcing/10-OptimisticConcurrency.Marten/Core/Entities/Aggregate.cs b/Workshops/IntroductionToEventSourcing/10-OptimisticConcurrency.Marten/Core/Entities/Aggregate.cs new file mode 100644 index 000000000..d5d146210 --- /dev/null +++ b/Workshops/IntroductionToEventSourcing/10-OptimisticConcurrency.Marten/Core/Entities/Aggregate.cs @@ -0,0 +1,39 @@ +namespace ApplicationLogic.Marten.Core.Entities; + +public interface IAggregate +{ + public void Apply(object @event); + + object[] DequeueUncommittedEvents(); +} + +public abstract class Aggregate: IAggregate +{ + public Guid Id { get; protected set; } = default!; + + private readonly Queue uncommittedEvents = new(); + + protected virtual void Apply(TEvent @event) { } + + public object[] DequeueUncommittedEvents() + { + var dequeuedEvents = uncommittedEvents.ToArray(); + + uncommittedEvents.Clear(); + + return dequeuedEvents; + } + + protected void Enqueue(object @event) + { + uncommittedEvents.Enqueue(@event); + } + + public void Apply(object @event) + { + if(@event is not TEvent typed) + return; + + Apply(typed); + } +} diff --git a/Workshops/IntroductionToEventSourcing/10-OptimisticConcurrency.Marten/Core/Exceptions/NotFoundException.cs b/Workshops/IntroductionToEventSourcing/10-OptimisticConcurrency.Marten/Core/Exceptions/NotFoundException.cs new file mode 100644 index 000000000..77586df02 --- /dev/null +++ b/Workshops/IntroductionToEventSourcing/10-OptimisticConcurrency.Marten/Core/Exceptions/NotFoundException.cs @@ -0,0 +1,13 @@ +namespace ApplicationLogic.Marten.Core.Exceptions; + +public class NotFoundException: Exception +{ + private NotFoundException(string typeName, string id): base($"{typeName} with id '{id}' was not found") + { + } + + public static NotFoundException For(Guid id) => + For(id.ToString()); + + public static NotFoundException For(string id) => new(typeof(T).Name, id); +} diff --git a/Workshops/IntroductionToEventSourcing/08-ApplicationLogic.Marten/Core/Http/ETagExtensions.cs b/Workshops/IntroductionToEventSourcing/10-OptimisticConcurrency.Marten/Core/Http/ETagExtensions.cs similarity index 100% rename from Workshops/IntroductionToEventSourcing/08-ApplicationLogic.Marten/Core/Http/ETagExtensions.cs rename to Workshops/IntroductionToEventSourcing/10-OptimisticConcurrency.Marten/Core/Http/ETagExtensions.cs diff --git a/Workshops/IntroductionToEventSourcing/10-OptimisticConcurrency.Marten/Core/Marten/DocumentSessionExtensions.cs b/Workshops/IntroductionToEventSourcing/10-OptimisticConcurrency.Marten/Core/Marten/DocumentSessionExtensions.cs new file mode 100644 index 000000000..d51da9ae2 --- /dev/null +++ b/Workshops/IntroductionToEventSourcing/10-OptimisticConcurrency.Marten/Core/Marten/DocumentSessionExtensions.cs @@ -0,0 +1,44 @@ +using ApplicationLogic.Marten.Core.Entities; +using ApplicationLogic.Marten.Core.Exceptions; +using Marten; + +namespace ApplicationLogic.Marten.Core.Marten; + +public static class DocumentSessionExtensions +{ + public static Task Add(this IDocumentSession documentSession, Guid id, object @event, CancellationToken ct) + where T : class + { + documentSession.Events.StartStream(id, @event); + return documentSession.SaveChangesAsync(token: ct); + } + + public static Task Add(this IDocumentSession documentSession, Guid id, object[] events, CancellationToken ct) + where T : class + { + documentSession.Events.StartStream(id, events); + return documentSession.SaveChangesAsync(token: ct); + } + + public static Task GetAndUpdate( + this IDocumentSession documentSession, + Guid id, + Func handle, + CancellationToken ct + ) where T : class => + documentSession.Events.WriteToAggregate(id, stream => + stream.AppendMany(handle(stream.Aggregate ?? throw NotFoundException.For(id))), ct); + + public static Task GetAndUpdate( + this IDocumentSession documentSession, + Guid id, + Action handle, + CancellationToken ct + ) where T : class, IAggregate => + documentSession.Events.WriteToAggregate(id, stream => + { + var aggregate = stream.Aggregate ?? throw NotFoundException.For(id); + handle(aggregate); + stream.AppendMany(aggregate.DequeueUncommittedEvents()); + }, ct); +} diff --git a/Workshops/IntroductionToEventSourcing/10-OptimisticConcurrency.Marten/Core/Marten/EventMappings.cs b/Workshops/IntroductionToEventSourcing/10-OptimisticConcurrency.Marten/Core/Marten/EventMappings.cs new file mode 100644 index 000000000..53865724d --- /dev/null +++ b/Workshops/IntroductionToEventSourcing/10-OptimisticConcurrency.Marten/Core/Marten/EventMappings.cs @@ -0,0 +1,12 @@ +using Marten; + +namespace ApplicationLogic.Marten.Core.Marten; + +public static class EventMappings +{ + public static StoreOptions MapEventWithPrefix(this StoreOptions options, string prefix) where T : class + { + options.Events.MapEventType($"{prefix}-${typeof(T).FullName}"); + return options; + } +} diff --git a/Workshops/IntroductionToEventSourcing/10-OptimisticConcurrency.Marten/Immutable/BusinessLogic.cs b/Workshops/IntroductionToEventSourcing/10-OptimisticConcurrency.Marten/Immutable/BusinessLogic.cs deleted file mode 100644 index afab8942c..000000000 --- a/Workshops/IntroductionToEventSourcing/10-OptimisticConcurrency.Marten/Immutable/BusinessLogic.cs +++ /dev/null @@ -1,258 +0,0 @@ -namespace IntroductionToEventSourcing.OptimisticConcurrency.Immutable; -using static ShoppingCartEvent; - -// ENTITY -public record ShoppingCart( - Guid Id, - Guid ClientId, - ShoppingCartStatus Status, - PricedProductItem[] ProductItems, - DateTime? ConfirmedAt = null, - DateTime? CanceledAt = null -){ - public bool IsClosed => ShoppingCartStatus.Closed.HasFlag(Status); - - public static ShoppingCart Create(ShoppingCartOpened opened) => - new ShoppingCart( - opened.ShoppingCartId, - opened.ClientId, - ShoppingCartStatus.Pending, - [] - ); - - public ShoppingCart Apply(ProductItemAddedToShoppingCart productItemAdded) => - this with - { - ProductItems = ProductItems - .Concat(new[] { productItemAdded.ProductItem }) - .GroupBy(pi => pi.ProductId) - .Select(group => group.Count() == 1 - ? group.First() - : new PricedProductItem( - group.Key, - group.Sum(pi => pi.Quantity), - group.First().UnitPrice - ) - ) - .ToArray() - }; - - public ShoppingCart Apply(ProductItemRemovedFromShoppingCart productItemRemoved) => - this with - { - ProductItems = ProductItems - .Select(pi => pi.ProductId == productItemRemoved.ProductItem.ProductId - ? new PricedProductItem( - pi.ProductId, - pi.Quantity - productItemRemoved.ProductItem.Quantity, - pi.UnitPrice - ) - : pi - ) - .Where(pi => pi.Quantity > 0) - .ToArray() - }; - - public ShoppingCart Apply(ShoppingCartConfirmed confirmed) => - this with - { - Status = ShoppingCartStatus.Confirmed, - ConfirmedAt = confirmed.ConfirmedAt - }; - - public ShoppingCart Apply(ShoppingCartCanceled canceled) => - this with - { - Status = ShoppingCartStatus.Canceled, - CanceledAt = canceled.CanceledAt - }; - - public bool HasEnough(PricedProductItem productItem) - { - var (productId, quantity, _) = productItem; - var currentQuantity = ProductItems.Where(pi => pi.ProductId == productId) - .Select(pi => pi.Quantity) - .FirstOrDefault(); - - return currentQuantity >= quantity; - } -} - -public enum ShoppingCartStatus -{ - Pending = 1, - Confirmed = 2, - Canceled = 4, - - Closed = Confirmed | Canceled -} - -public record OpenShoppingCart( - Guid ShoppingCartId, - Guid ClientId -) -{ - public static OpenShoppingCart From(Guid? cartId, Guid? clientId) - { - if (cartId == null || cartId == Guid.Empty) - throw new ArgumentOutOfRangeException(nameof(cartId)); - if (clientId == null || clientId == Guid.Empty) - throw new ArgumentOutOfRangeException(nameof(clientId)); - - return new OpenShoppingCart(cartId.Value, clientId.Value); - } - - public static ShoppingCartOpened Handle(OpenShoppingCart command) - { - var (shoppingCartId, clientId) = command; - - return new ShoppingCartOpened( - shoppingCartId, - clientId - ); - } -} - -public record AddProductItemToShoppingCart( - Guid ShoppingCartId, - ProductItem ProductItem -) -{ - public static AddProductItemToShoppingCart From(Guid? cartId, ProductItem? productItem) - { - if (cartId == null || cartId == Guid.Empty) - throw new ArgumentOutOfRangeException(nameof(cartId)); - if (productItem == null) - throw new ArgumentOutOfRangeException(nameof(productItem)); - - return new AddProductItemToShoppingCart(cartId.Value, productItem); - } - - public static ProductItemAddedToShoppingCart Handle( - IProductPriceCalculator productPriceCalculator, - AddProductItemToShoppingCart command, - ShoppingCart shoppingCart - ) - { - var (cartId, productItem) = command; - - if (shoppingCart.IsClosed) - throw new InvalidOperationException( - $"Adding product item for cart in '{shoppingCart.Status}' status is not allowed."); - - var pricedProductItem = productPriceCalculator.Calculate(productItem); - - return new ProductItemAddedToShoppingCart( - cartId, - pricedProductItem - ); - } -} - -public record RemoveProductItemFromShoppingCart( - Guid ShoppingCartId, - PricedProductItem ProductItem -) -{ - public static RemoveProductItemFromShoppingCart From(Guid? cartId, PricedProductItem? productItem) - { - if (cartId == null || cartId == Guid.Empty) - throw new ArgumentOutOfRangeException(nameof(cartId)); - if (productItem == null) - throw new ArgumentOutOfRangeException(nameof(productItem)); - - return new RemoveProductItemFromShoppingCart(cartId.Value, productItem); - } - - public static ProductItemRemovedFromShoppingCart Handle( - RemoveProductItemFromShoppingCart command, - ShoppingCart shoppingCart - ) - { - var (cartId, productItem) = command; - - if (shoppingCart.IsClosed) - throw new InvalidOperationException( - $"Adding product item for cart in '{shoppingCart.Status}' status is not allowed."); - - if (!shoppingCart.HasEnough(productItem)) - throw new InvalidOperationException("Not enough product items to remove"); - - return new ProductItemRemovedFromShoppingCart( - cartId, - productItem - ); - } -} - -public record ConfirmShoppingCart( - Guid ShoppingCartId -) -{ - public static ConfirmShoppingCart From(Guid? cartId) - { - if (cartId == null || cartId == Guid.Empty) - throw new ArgumentOutOfRangeException(nameof(cartId)); - - return new ConfirmShoppingCart(cartId.Value); - } - - public static ShoppingCartConfirmed Handle(ConfirmShoppingCart command, ShoppingCart shoppingCart) - { - if (shoppingCart.IsClosed) - throw new InvalidOperationException($"Confirming cart in '{shoppingCart.Status}' status is not allowed."); - - return new ShoppingCartConfirmed( - shoppingCart.Id, - DateTime.UtcNow - ); - } -} - -public record CancelShoppingCart( - Guid ShoppingCartId -) -{ - public static CancelShoppingCart From(Guid? cartId) - { - if (cartId == null || cartId == Guid.Empty) - throw new ArgumentOutOfRangeException(nameof(cartId)); - - return new CancelShoppingCart(cartId.Value); - } - - public static ShoppingCartCanceled Handle(CancelShoppingCart command, ShoppingCart shoppingCart) - { - if (shoppingCart.IsClosed) - throw new InvalidOperationException($"Canceling cart in '{shoppingCart.Status}' status is not allowed."); - - return new ShoppingCartCanceled( - shoppingCart.Id, - DateTime.UtcNow - ); - } -} - -public interface IProductPriceCalculator -{ - PricedProductItem Calculate(ProductItem productItems); -} - -public class FakeProductPriceCalculator: IProductPriceCalculator -{ - private readonly int value; - - private FakeProductPriceCalculator(int value) - { - this.value = value; - } - - public static FakeProductPriceCalculator Returning(int value) => new(value); - - public PricedProductItem Calculate(ProductItem productItem) - { - var (productId, quantity) = productItem; - - return new PricedProductItem(productId, quantity, value); - } -} diff --git a/Workshops/IntroductionToEventSourcing/10-OptimisticConcurrency.Marten/Immutable/DocumentSessionExtensions.cs b/Workshops/IntroductionToEventSourcing/10-OptimisticConcurrency.Marten/Immutable/DocumentSessionExtensions.cs deleted file mode 100644 index 043a09482..000000000 --- a/Workshops/IntroductionToEventSourcing/10-OptimisticConcurrency.Marten/Immutable/DocumentSessionExtensions.cs +++ /dev/null @@ -1,41 +0,0 @@ -using Marten; - -namespace IntroductionToEventSourcing.OptimisticConcurrency.Immutable; - -public static class DocumentSessionExtensions -{ - public static Task Get( - this IDocumentSession session, - Guid id, - CancellationToken cancellationToken = default - ) where TEntity : class - { - // Fill logic here. - throw new NotImplementedException(); - } - - public static Task Add( - this IDocumentSession session, - Func getId, - Func action, - TCommand command, - CancellationToken cancellationToken = default - ) where TEntity : class - { - // Fill logic here. - throw new NotImplementedException(); - } - - public static Task GetAndUpdate( - this IDocumentSession session, - Func getId, - Func action, - TCommand command, - long version, - CancellationToken cancellationToken = default - ) where TEntity : class - { - // Fill logic here. - throw new NotImplementedException(); - } -} diff --git a/Workshops/IntroductionToEventSourcing/10-OptimisticConcurrency.Marten/Immutable/OptimisticConcurrencyTests.cs b/Workshops/IntroductionToEventSourcing/10-OptimisticConcurrency.Marten/Immutable/OptimisticConcurrencyTests.cs deleted file mode 100644 index 2f4aa36d9..000000000 --- a/Workshops/IntroductionToEventSourcing/10-OptimisticConcurrency.Marten/Immutable/OptimisticConcurrencyTests.cs +++ /dev/null @@ -1,132 +0,0 @@ -using FluentAssertions; -using IntroductionToEventSourcing.OptimisticConcurrency.Tools; -using Marten.Exceptions; -using Xunit; - -namespace IntroductionToEventSourcing.OptimisticConcurrency.Immutable; - -// EVENTS -public abstract record ShoppingCartEvent -{ - public record ShoppingCartOpened( - Guid ShoppingCartId, - Guid ClientId - ): ShoppingCartEvent; - - public record ProductItemAddedToShoppingCart( - Guid ShoppingCartId, - PricedProductItem ProductItem - ): ShoppingCartEvent; - - public record ProductItemRemovedFromShoppingCart( - Guid ShoppingCartId, - PricedProductItem ProductItem - ): ShoppingCartEvent; - - public record ShoppingCartConfirmed( - Guid ShoppingCartId, - DateTime ConfirmedAt - ): ShoppingCartEvent; - - public record ShoppingCartCanceled( - Guid ShoppingCartId, - DateTime CanceledAt - ): ShoppingCartEvent; - - // This won't allow external inheritance - private ShoppingCartEvent(){} -} - -// VALUE OBJECTS - -public record ProductItem( - Guid ProductId, - int Quantity -); - -public record PricedProductItem( - Guid ProductId, - int Quantity, - decimal UnitPrice -); - -// Business logic - -public class OptimisticConcurrencyTests: MartenTest -{ - [Fact] - [Trait("Category", "SkipCI")] - public async Task GettingState_ForSequenceOfEvents_ShouldSucceed() - { - var shoppingCartId = Guid.NewGuid(); - var clientId = Guid.NewGuid(); - var shoesId = Guid.NewGuid(); - var tShirtId = Guid.NewGuid(); - var twoPairsOfShoes = new ProductItem(shoesId, 2); - var tShirt = new ProductItem(tShirtId, 1); - - var shoesPrice = 100; - var tShirtPrice = 50; - - // Open - await DocumentSession.Add( - command => command.ShoppingCartId, - OpenShoppingCart.Handle, - OpenShoppingCart.From(shoppingCartId, clientId), - CancellationToken.None - ); - - // Try to open again - // Should fail as stream was already created - var exception = await Record.ExceptionAsync(async () => - { - await DocumentSession.Add( - command => command.ShoppingCartId, - OpenShoppingCart.Handle, - OpenShoppingCart.From(shoppingCartId, clientId), - CancellationToken.None - ); - } - ); - exception.Should().BeOfType(); - ReOpenSession(); - - // Add two pairs of shoes - await DocumentSession.GetAndUpdate( - command => command.ShoppingCartId, - (command, shoppingCart) => - AddProductItemToShoppingCart.Handle(FakeProductPriceCalculator.Returning(shoesPrice), command, shoppingCart), - AddProductItemToShoppingCart.From(shoppingCartId, twoPairsOfShoes), - 1, - CancellationToken.None - ); - - // Add T-Shirt - // Should fail because of sending the same expected version as previous call - exception = await Record.ExceptionAsync(async () => - { - await DocumentSession.GetAndUpdate( - command => command.ShoppingCartId, - (command, shoppingCart) => - AddProductItemToShoppingCart.Handle(FakeProductPriceCalculator.Returning(tShirtPrice), command, shoppingCart), - AddProductItemToShoppingCart.From(shoppingCartId, tShirt), - 1, - CancellationToken.None - ); - } - ); - exception.Should().BeOfType(); - ReOpenSession(); - - var shoppingCart = await DocumentSession.Get(shoppingCartId, CancellationToken.None); - - shoppingCart.Id.Should().Be(shoppingCartId); - shoppingCart.ClientId.Should().Be(clientId); - shoppingCart.ProductItems.Should().HaveCount(1); - shoppingCart.Status.Should().Be(ShoppingCartStatus.Pending); - - shoppingCart.ProductItems[0].ProductId.Should().Be(shoesId); - shoppingCart.ProductItems[0].Quantity.Should().Be(twoPairsOfShoes.Quantity); - shoppingCart.ProductItems[0].UnitPrice.Should().Be(shoesPrice); - } -} diff --git a/Workshops/IntroductionToEventSourcing/10-OptimisticConcurrency.Marten/Immutable/Pricing/PricingCalculator.cs b/Workshops/IntroductionToEventSourcing/10-OptimisticConcurrency.Marten/Immutable/Pricing/PricingCalculator.cs new file mode 100644 index 000000000..c999ee055 --- /dev/null +++ b/Workshops/IntroductionToEventSourcing/10-OptimisticConcurrency.Marten/Immutable/Pricing/PricingCalculator.cs @@ -0,0 +1,26 @@ +using ApplicationLogic.Marten.Immutable.ShoppingCarts; + +namespace ApplicationLogic.Marten.Immutable.Pricing; + +public interface IProductPriceCalculator +{ + PricedProductItem Calculate(ProductItem productItems); +} + +public class FakeProductPriceCalculator: IProductPriceCalculator +{ + private readonly int value; + + private FakeProductPriceCalculator(int value) + { + this.value = value; + } + + public static FakeProductPriceCalculator Returning(int value) => new(value); + + public PricedProductItem Calculate(ProductItem productItem) + { + var (productId, quantity) = productItem; + return new PricedProductItem(productId, quantity, value); + } +} diff --git a/Workshops/IntroductionToEventSourcing/10-OptimisticConcurrency.Marten/Immutable/Pricing/ProductItems.cs b/Workshops/IntroductionToEventSourcing/10-OptimisticConcurrency.Marten/Immutable/Pricing/ProductItems.cs new file mode 100644 index 000000000..1fc2ef71e --- /dev/null +++ b/Workshops/IntroductionToEventSourcing/10-OptimisticConcurrency.Marten/Immutable/Pricing/ProductItems.cs @@ -0,0 +1,13 @@ +namespace ApplicationLogic.Marten.Immutable.Pricing; + +public record PricedProductItem( + Guid ProductId, + int Quantity, + decimal UnitPrice +) +{ + public decimal TotalPrice => Quantity * UnitPrice; +} + +public record ProductItem(Guid ProductId, int Quantity); + diff --git a/Workshops/IntroductionToEventSourcing/10-OptimisticConcurrency.Marten/Immutable/ShoppingCarts/Api.cs b/Workshops/IntroductionToEventSourcing/10-OptimisticConcurrency.Marten/Immutable/ShoppingCarts/Api.cs new file mode 100644 index 000000000..e027e0639 --- /dev/null +++ b/Workshops/IntroductionToEventSourcing/10-OptimisticConcurrency.Marten/Immutable/ShoppingCarts/Api.cs @@ -0,0 +1,133 @@ +using ApplicationLogic.Marten.Core.Marten; +using ApplicationLogic.Marten.Immutable.Pricing; +using Core.Validation; +using Marten; +using Marten.Schema.Identity; +using Microsoft.AspNetCore.Mvc; +using static Microsoft.AspNetCore.Http.TypedResults; +using static System.DateTimeOffset; + +namespace ApplicationLogic.Marten.Immutable.ShoppingCarts; + +using static ShoppingCartService; +using static ShoppingCartCommand; + +public static class Api +{ + public static WebApplication ConfigureImmutableShoppingCarts(this WebApplication app) + { + var clients = app.MapGroup("/api/immutable/clients/{clientId:guid}/"); + var shoppingCarts = clients.MapGroup("shopping-carts"); + var shoppingCart = shoppingCarts.MapGroup("{shoppingCartId:guid}"); + var productItems = shoppingCart.MapGroup("product-items"); + + shoppingCarts.MapPost("", + async (IDocumentSession session, + Guid clientId, + CancellationToken ct) => + { + var shoppingCartId = CombGuidIdGeneration.NewGuid(); + + await session.Add(shoppingCartId, + [Handle(new OpenShoppingCart(shoppingCartId, clientId.NotEmpty(), Now))], ct); + + return Created($"/api/immutable/clients/{clientId}/shopping-carts/{shoppingCartId}", shoppingCartId); + } + ); + + productItems.MapPost("", + async ( + IProductPriceCalculator pricingCalculator, + IDocumentSession session, + Guid shoppingCartId, + AddProductRequest body, + CancellationToken ct) => + { + var productItem = body.ProductItem.NotNull().ToProductItem(); + + await session.GetAndUpdate(shoppingCartId, + state => + [ + Handle(pricingCalculator, + new AddProductItemToShoppingCart(shoppingCartId, productItem, Now), + state) + ], ct); + + return NoContent(); + } + ); + + productItems.MapDelete("{productId:guid}", + async ( + IDocumentSession session, + Guid shoppingCartId, + [FromRoute] Guid productId, + [FromQuery] int? quantity, + [FromQuery] decimal? unitPrice, + CancellationToken ct) => + { + var productItem = new PricedProductItem( + productId.NotEmpty(), + quantity.NotNull().Positive(), + unitPrice.NotNull().Positive() + ); + + await session.GetAndUpdate(shoppingCartId, + state => [Handle(new RemoveProductItemFromShoppingCart(shoppingCartId, productItem, Now), state)], + ct); + + return NoContent(); + } + ); + + shoppingCart.MapPost("confirm", + async (IDocumentSession session, + Guid shoppingCartId, + CancellationToken ct) => + { + await session.GetAndUpdate(shoppingCartId, + state => [Handle(new ConfirmShoppingCart(shoppingCartId, Now), state)], ct); + + return NoContent(); + } + ); + + shoppingCart.MapDelete("", + async (IDocumentSession session, + Guid shoppingCartId, + CancellationToken ct) => + { + await session.GetAndUpdate(shoppingCartId, + state => [Handle(new CancelShoppingCart(shoppingCartId, Now), state)], ct); + + return NoContent(); + } + ); + + shoppingCart.MapGet("", + async Task ( + IQuerySession session, + Guid shoppingCartId, + CancellationToken ct) => + { + var result = await session.Events.AggregateStreamAsync(shoppingCartId, token: ct); + + return result is not null ? Ok(result) : NotFound(); + } + ); + + return app; + } +} + +public record ProductItemRequest( + Guid? ProductId, + int? Quantity +) +{ + public ProductItem ToProductItem() => new(ProductId.NotEmpty(), Quantity.NotNull().Positive()); +} + +public record AddProductRequest( + ProductItemRequest? ProductItem +); diff --git a/Workshops/IntroductionToEventSourcing/10-OptimisticConcurrency.Marten/Immutable/ShoppingCarts/Configure.cs b/Workshops/IntroductionToEventSourcing/10-OptimisticConcurrency.Marten/Immutable/ShoppingCarts/Configure.cs new file mode 100644 index 000000000..c79e79181 --- /dev/null +++ b/Workshops/IntroductionToEventSourcing/10-OptimisticConcurrency.Marten/Immutable/ShoppingCarts/Configure.cs @@ -0,0 +1,30 @@ +using ApplicationLogic.Marten.Core.Marten; +using ApplicationLogic.Marten.Immutable.Pricing; +using Marten; + +namespace ApplicationLogic.Marten.Immutable.ShoppingCarts; +using static ShoppingCartEvent; + +public static class Configure +{ + private const string ModulePrefix = "immutable"; + public static IServiceCollection AddImmutableShoppingCarts(this IServiceCollection services) + { + services.AddSingleton(FakeProductPriceCalculator.Returning(100)); + + return services; + } + public static StoreOptions ConfigureImmutableShoppingCarts(this StoreOptions options) + { + options.Projections.LiveStreamAggregation(); + + // this is needed as we're sharing document store and have event types with the same name + options.MapEventWithPrefix(ModulePrefix); + options.MapEventWithPrefix(ModulePrefix); + options.MapEventWithPrefix(ModulePrefix); + options.MapEventWithPrefix(ModulePrefix); + options.MapEventWithPrefix(ModulePrefix); + + return options; + } +} diff --git a/Workshops/IntroductionToEventSourcing/10-OptimisticConcurrency.Marten/Immutable/ShoppingCarts/ShoppingCart.cs b/Workshops/IntroductionToEventSourcing/10-OptimisticConcurrency.Marten/Immutable/ShoppingCarts/ShoppingCart.cs new file mode 100644 index 000000000..b8356e553 --- /dev/null +++ b/Workshops/IntroductionToEventSourcing/10-OptimisticConcurrency.Marten/Immutable/ShoppingCarts/ShoppingCart.cs @@ -0,0 +1,132 @@ +using ApplicationLogic.Marten.Immutable.Pricing; + +namespace ApplicationLogic.Marten.Immutable.ShoppingCarts; +using static ShoppingCartEvent; + +// EVENTS +public abstract record ShoppingCartEvent +{ + public record ShoppingCartOpened( + Guid ShoppingCartId, + Guid ClientId, + DateTimeOffset OpenedAt + ): ShoppingCartEvent; + + public record ProductItemAddedToShoppingCart( + Guid ShoppingCartId, + PricedProductItem ProductItem, + DateTimeOffset AddedAt + ): ShoppingCartEvent; + + public record ProductItemRemovedFromShoppingCart( + Guid ShoppingCartId, + PricedProductItem ProductItem, + DateTimeOffset RemovedAt + ): ShoppingCartEvent; + + public record ShoppingCartConfirmed( + Guid ShoppingCartId, + DateTimeOffset ConfirmedAt + ): ShoppingCartEvent; + + public record ShoppingCartCanceled( + Guid ShoppingCartId, + DateTimeOffset CanceledAt + ): ShoppingCartEvent; + + // This won't allow external inheritance + private ShoppingCartEvent(){} +} + +// ENTITY +public record ShoppingCart( + Guid Id, + Guid ClientId, + ShoppingCartStatus Status, + PricedProductItem[] ProductItems, + DateTimeOffset OpenedAt, + DateTimeOffset? ConfirmedAt = null, + DateTimeOffset? CanceledAt = null +) +{ + public bool IsClosed => ShoppingCartStatus.Closed.HasFlag(Status); + + public bool HasEnough(PricedProductItem productItem) + { + var (productId, quantity, _) = productItem; + var currentQuantity = ProductItems.Where(pi => pi.ProductId == productId) + .Select(pi => pi.Quantity) + .FirstOrDefault(); + + return currentQuantity >= quantity; + } + + public static ShoppingCart Default() => + new (default, default, default, [], default); + + public ShoppingCart Apply(ShoppingCartEvent @event) + { + return @event switch + { + ShoppingCartOpened(var shoppingCartId, var clientId, _) => + this with + { + Id = shoppingCartId, + ClientId = clientId, + Status = ShoppingCartStatus.Pending + }, + ProductItemAddedToShoppingCart(_, var pricedProductItem, _) => + this with + { + ProductItems = ProductItems + .Concat(new [] { pricedProductItem }) + .GroupBy(pi => pi.ProductId) + .Select(group => group.Count() == 1? + group.First() + : new PricedProductItem( + group.Key, + group.Sum(pi => pi.Quantity), + group.First().UnitPrice + ) + ) + .ToArray() + }, + ProductItemRemovedFromShoppingCart(_, var pricedProductItem, _) => + this with + { + ProductItems = ProductItems + .Select(pi => pi.ProductId == pricedProductItem.ProductId? + pi with { Quantity = pi.Quantity - pricedProductItem.Quantity } + :pi + ) + .Where(pi => pi.Quantity > 0) + .ToArray() + }, + ShoppingCartConfirmed(_, var confirmedAt) => + this with + { + Status = ShoppingCartStatus.Confirmed, + ConfirmedAt = confirmedAt + }, + ShoppingCartCanceled(_, var canceledAt) => + this with + { + Status = ShoppingCartStatus.Canceled, + CanceledAt = canceledAt + }, + _ => this + }; + } + + // let's make Marten happy + private ShoppingCart():this(Guid.Empty, Guid.Empty, ShoppingCartStatus.Pending, [], default){} +} + +public enum ShoppingCartStatus +{ + Pending = 1, + Confirmed = 2, + Canceled = 4, + + Closed = Confirmed | Canceled +} diff --git a/Workshops/IntroductionToEventSourcing/10-OptimisticConcurrency.Marten/Immutable/ShoppingCarts/ShoppingCartService.cs b/Workshops/IntroductionToEventSourcing/10-OptimisticConcurrency.Marten/Immutable/ShoppingCarts/ShoppingCartService.cs new file mode 100644 index 000000000..d19cde607 --- /dev/null +++ b/Workshops/IntroductionToEventSourcing/10-OptimisticConcurrency.Marten/Immutable/ShoppingCarts/ShoppingCartService.cs @@ -0,0 +1,120 @@ +using ApplicationLogic.Marten.Immutable.Pricing; +using Core.Validation; + +namespace ApplicationLogic.Marten.Immutable.ShoppingCarts; +using static ShoppingCartEvent; +using static ShoppingCartCommand; + +public abstract record ShoppingCartCommand +{ + public record OpenShoppingCart( + Guid ShoppingCartId, + Guid ClientId, + DateTimeOffset Now + ): ShoppingCartCommand; + + public record AddProductItemToShoppingCart( + Guid ShoppingCartId, + ProductItem ProductItem, + DateTimeOffset Now + ); + + public record RemoveProductItemFromShoppingCart( + Guid ShoppingCartId, + PricedProductItem ProductItem, + DateTimeOffset Now + ); + + public record ConfirmShoppingCart( + Guid ShoppingCartId, + DateTimeOffset Now + ); + + public record CancelShoppingCart( + Guid ShoppingCartId, + DateTimeOffset Now + ): ShoppingCartCommand; + + private ShoppingCartCommand() {} +} + +public static class ShoppingCartService +{ + public static ShoppingCartOpened Handle(OpenShoppingCart command) + { + var (shoppingCartId, clientId, now) = command; + + return new ShoppingCartOpened( + shoppingCartId, + clientId, + now + ); + } + + public static ProductItemAddedToShoppingCart Handle( + IProductPriceCalculator priceCalculator, + AddProductItemToShoppingCart command, + ShoppingCart shoppingCart + ) + { + var (cartId, productItem, now) = command; + + if (shoppingCart.IsClosed) + throw new InvalidOperationException( + $"Adding product item for cart in '{shoppingCart.Status}' status is not allowed."); + + var pricedProductItem = priceCalculator.Calculate(productItem); + + return new ProductItemAddedToShoppingCart( + cartId, + pricedProductItem, + now + ); + } + + public static ProductItemRemovedFromShoppingCart Handle( + RemoveProductItemFromShoppingCart command, + ShoppingCart shoppingCart + ) + { + var (cartId, productItem, now) = command; + + if (shoppingCart.IsClosed) + throw new InvalidOperationException( + $"Adding product item for cart in '{shoppingCart.Status}' status is not allowed."); + + if (!shoppingCart.HasEnough(productItem)) + throw new InvalidOperationException("Not enough product items to remove"); + + return new ProductItemRemovedFromShoppingCart( + cartId, + productItem, + now + ); + } + + public static ShoppingCartConfirmed Handle(ConfirmShoppingCart command, ShoppingCart shoppingCart) + { + if (shoppingCart.IsClosed) + throw new InvalidOperationException($"Confirming cart in '{shoppingCart.Status}' status is not allowed."); + + if(shoppingCart.ProductItems.Length == 0) + throw new InvalidOperationException($"Cannot confirm empty shopping cart"); + + return new ShoppingCartConfirmed( + shoppingCart.Id, + DateTime.UtcNow + ); + } + + public static ShoppingCartCanceled Handle(CancelShoppingCart command, ShoppingCart shoppingCart) + { + if (shoppingCart.IsClosed) + throw new InvalidOperationException($"Canceling cart in '{shoppingCart.Status}' status is not allowed."); + + return new ShoppingCartCanceled( + shoppingCart.Id, + command.Now + ); + } +} diff --git a/Workshops/IntroductionToEventSourcing/10-OptimisticConcurrency.Marten/Mixed/DocumentSessionExtensions.cs b/Workshops/IntroductionToEventSourcing/10-OptimisticConcurrency.Marten/Mixed/DocumentSessionExtensions.cs deleted file mode 100644 index 59f0c80aa..000000000 --- a/Workshops/IntroductionToEventSourcing/10-OptimisticConcurrency.Marten/Mixed/DocumentSessionExtensions.cs +++ /dev/null @@ -1,41 +0,0 @@ -using Marten; - -namespace IntroductionToEventSourcing.OptimisticConcurrency.Mixed; - -public static class DocumentSessionExtensions -{ - public static Task Get( - this IDocumentSession session, - Guid id, - CancellationToken cancellationToken = default - ) where TAggregate : class, IAggregate - { - // Fill logic here. - throw new NotImplementedException(); - } - - public static Task Add( - this IDocumentSession session, - Func getId, - Func action, - TCommand command, - CancellationToken cancellationToken = default - ) where TAggregate : class, IAggregate - { - // Fill logic here. - throw new NotImplementedException(); - } - - public static Task GetAndUpdate( - this IDocumentSession session, - Func getId, - Func action, - TCommand command, - long version, - CancellationToken cancellationToken = default - ) where TAggregate : class, IAggregate - { - // Fill logic here. - throw new NotImplementedException(); - } -} diff --git a/Workshops/IntroductionToEventSourcing/10-OptimisticConcurrency.Marten/Mixed/OptimisticConcurrencyTests.cs b/Workshops/IntroductionToEventSourcing/10-OptimisticConcurrency.Marten/Mixed/OptimisticConcurrencyTests.cs deleted file mode 100644 index 35c48d0a4..000000000 --- a/Workshops/IntroductionToEventSourcing/10-OptimisticConcurrency.Marten/Mixed/OptimisticConcurrencyTests.cs +++ /dev/null @@ -1,147 +0,0 @@ -using FluentAssertions; -using IntroductionToEventSourcing.OptimisticConcurrency.Tools; -using Marten.Exceptions; -using Xunit; - -namespace IntroductionToEventSourcing.OptimisticConcurrency.Mixed; - -// EVENTS -public abstract record ShoppingCartEvent -{ - public record ShoppingCartOpened( - Guid ShoppingCartId, - Guid ClientId - ): ShoppingCartEvent; - - public record ProductItemAddedToShoppingCart( - Guid ShoppingCartId, - PricedProductItem ProductItem - ): ShoppingCartEvent; - - public record ProductItemRemovedFromShoppingCart( - Guid ShoppingCartId, - PricedProductItem ProductItem - ): ShoppingCartEvent; - - public record ShoppingCartConfirmed( - Guid ShoppingCartId, - DateTime ConfirmedAt - ): ShoppingCartEvent; - - public record ShoppingCartCanceled( - Guid ShoppingCartId, - DateTime CanceledAt - ): ShoppingCartEvent; - - // This won't allow external inheritance - private ShoppingCartEvent(){} -} - -// VALUE OBJECTS -public record ProductItem( - Guid ProductId, - int Quantity -); - -public record PricedProductItem( - Guid ProductId, - int Quantity, - decimal UnitPrice -); - -public static class ShoppingCartExtensions -{ - public static ShoppingCart GetShoppingCart(this IEnumerable events) - { - var shoppingCart = (ShoppingCart)Activator.CreateInstance(typeof(ShoppingCart), true)!; - - foreach (var @event in events) - { - shoppingCart.Evolve(@event); - } - - return shoppingCart; - } -} - -public class OptimisticConcurrencyTests: MartenTest -{ - [Fact] - [Trait("Category", "SkipCI")] - public async Task GettingState_ForSequenceOfEvents_ShouldSucceed() - { - var shoppingCartId = Guid.NewGuid(); - var clientId = Guid.NewGuid(); - var shoesId = Guid.NewGuid(); - var tShirtId = Guid.NewGuid(); - - var twoPairsOfShoes = new ProductItem(shoesId, 2); - var tShirt = new ProductItem(tShirtId, 1); - - var shoesPrice = 100; - var tShirtPrice = 50; - - // Open - await DocumentSession.Add( - command => command.ShoppingCartId, - command => - ShoppingCart.Open(command.ShoppingCartId, command.ClientId).Event, - OpenShoppingCart.From(shoppingCartId, clientId), - CancellationToken.None - ); - - // Try to open again - // Should fail as stream was already created - var exception = await Record.ExceptionAsync(async () => - { - await DocumentSession.Add( - command => command.ShoppingCartId, - command => - ShoppingCart.Open(command.ShoppingCartId, command.ClientId).Event, - OpenShoppingCart.From(shoppingCartId, clientId), - CancellationToken.None - ); - } - ); - exception.Should().BeOfType(); - ReOpenSession(); - - // Add two pairs of shoes - await DocumentSession.GetAndUpdate( - command => command.ShoppingCartId, - (command, shoppingCart) => - shoppingCart.AddProduct(FakeProductPriceCalculator.Returning(shoesPrice), command.ProductItem), - AddProductItemToShoppingCart.From(shoppingCartId, twoPairsOfShoes), - 1, - CancellationToken.None - ); - - // Add T-Shirt - // Should fail because of sending the same expected version as previous call - exception = await Record.ExceptionAsync(async () => - { - await DocumentSession.GetAndUpdate( - command => command.ShoppingCartId, - (command, shoppingCart) => - shoppingCart.AddProduct(FakeProductPriceCalculator.Returning(tShirtPrice), command.ProductItem), - AddProductItemToShoppingCart.From(shoppingCartId, tShirt), - 1, - CancellationToken.None - ); - } - ); - exception.Should().BeOfType(); - ReOpenSession(); - - var shoppingCart = await DocumentSession.Get(shoppingCartId, CancellationToken.None); - - shoppingCart.Id.Should().Be(shoppingCartId); - shoppingCart.ClientId.Should().Be(clientId); - shoppingCart.ProductItems.Should().HaveCount(1); - shoppingCart.Status.Should().Be(ShoppingCartStatus.Pending); - - shoppingCart.ProductItems[0].ProductId.Should().Be(shoesId); - shoppingCart.ProductItems[0].Quantity.Should().Be(twoPairsOfShoes.Quantity); - shoppingCart.ProductItems[0].UnitPrice.Should().Be(shoesPrice); - } -} diff --git a/Workshops/IntroductionToEventSourcing/10-OptimisticConcurrency.Marten/Mixed/Pricing/PricingCalculator.cs b/Workshops/IntroductionToEventSourcing/10-OptimisticConcurrency.Marten/Mixed/Pricing/PricingCalculator.cs new file mode 100644 index 000000000..304900448 --- /dev/null +++ b/Workshops/IntroductionToEventSourcing/10-OptimisticConcurrency.Marten/Mixed/Pricing/PricingCalculator.cs @@ -0,0 +1,21 @@ +namespace ApplicationLogic.Marten.Mixed.Pricing; + +public interface IProductPriceCalculator +{ + PricedProductItem Calculate(ProductItem productItems); +} + +public class FakeProductPriceCalculator: IProductPriceCalculator +{ + private readonly int value; + + private FakeProductPriceCalculator(int value) + { + this.value = value; + } + + public static FakeProductPriceCalculator Returning(int value) => new(value); + + public PricedProductItem Calculate(ProductItem productItem) => + new() { ProductId = productItem.ProductId, Quantity = productItem.Quantity, UnitPrice = value }; +} diff --git a/Workshops/IntroductionToEventSourcing/10-OptimisticConcurrency.Marten/Mixed/Pricing/ProductItems.cs b/Workshops/IntroductionToEventSourcing/10-OptimisticConcurrency.Marten/Mixed/Pricing/ProductItems.cs new file mode 100644 index 000000000..108702ca9 --- /dev/null +++ b/Workshops/IntroductionToEventSourcing/10-OptimisticConcurrency.Marten/Mixed/Pricing/ProductItems.cs @@ -0,0 +1,16 @@ +namespace ApplicationLogic.Marten.Mixed.Pricing; + +public class PricedProductItem +{ + public Guid ProductId { get; set; } + public decimal UnitPrice { get; set; } + public int Quantity { get; set; } + public decimal TotalPrice => Quantity * UnitPrice; +} + +public class ProductItem +{ + public Guid ProductId { get; set; } + public int Quantity { get; set; } +} + diff --git a/Workshops/IntroductionToEventSourcing/10-OptimisticConcurrency.Marten/Mixed/ShoppingCarts/Api.cs b/Workshops/IntroductionToEventSourcing/10-OptimisticConcurrency.Marten/Mixed/ShoppingCarts/Api.cs new file mode 100644 index 000000000..340061a2a --- /dev/null +++ b/Workshops/IntroductionToEventSourcing/10-OptimisticConcurrency.Marten/Mixed/ShoppingCarts/Api.cs @@ -0,0 +1,125 @@ +using ApplicationLogic.Marten.Core.Marten; +using ApplicationLogic.Marten.Mixed.Pricing; +using Core.Validation; +using Marten; +using Marten.Schema.Identity; +using Microsoft.AspNetCore.Mvc; +using static Microsoft.AspNetCore.Http.TypedResults; +using static System.DateTimeOffset; + +namespace ApplicationLogic.Marten.Mixed.ShoppingCarts; +public static class Api +{ + public static WebApplication ConfigureMixedShoppingCarts(this WebApplication app) + { + var clients = app.MapGroup("/api/mixed/clients/{clientId:guid}/"); + var shoppingCarts = clients.MapGroup("shopping-carts"); + var shoppingCart = shoppingCarts.MapGroup("{shoppingCartId:guid}"); + var productItems = shoppingCart.MapGroup("product-items"); + + shoppingCarts.MapPost("", + async (IDocumentSession session, + Guid clientId, + CancellationToken ct) => + { + var shoppingCartId = CombGuidIdGeneration.NewGuid(); + + await session.Add(shoppingCartId, + [MixedShoppingCart.Open(shoppingCartId, clientId.NotEmpty(), Now).Item1], ct); + + return Created($"/api/mixed/clients/{clientId}/shopping-carts/{shoppingCartId}", shoppingCartId); + } + ); + + productItems.MapPost("", + async ( + IProductPriceCalculator pricingCalculator, + IDocumentSession session, + Guid shoppingCartId, + AddProductRequest body, + CancellationToken ct) => + { + var productItem = body.ProductItem.NotNull().ToProductItem(); + + await session.GetAndUpdate(shoppingCartId, + state => [state.AddProduct(pricingCalculator, productItem, Now)], ct); + + return NoContent(); + } + ); + + productItems.MapDelete("{productId:guid}", + async ( + IDocumentSession session, + Guid shoppingCartId, + [FromRoute] Guid productId, + [FromQuery] int? quantity, + [FromQuery] decimal? unitPrice, + CancellationToken ct) => + { + var productItem = new PricedProductItem + { + ProductId = productId.NotEmpty(), + Quantity = quantity.NotNull().Positive(), + UnitPrice = unitPrice.NotNull().Positive() + }; + + await session.GetAndUpdate(shoppingCartId, + state => [state.RemoveProduct(productItem, Now)], + ct); + + return NoContent(); + } + ); + + shoppingCart.MapPost("confirm", + async (IDocumentSession session, + Guid shoppingCartId, + CancellationToken ct) => + { + await session.GetAndUpdate(shoppingCartId, + state => [state.Confirm(Now)], ct); + + return NoContent(); + } + ); + + shoppingCart.MapDelete("", + async (IDocumentSession session, + Guid shoppingCartId, + CancellationToken ct) => + { + await session.GetAndUpdate(shoppingCartId, + state => [state.Cancel(Now)], ct); + + return NoContent(); + } + ); + + shoppingCart.MapGet("", + async Task ( + IQuerySession session, + Guid shoppingCartId, + CancellationToken ct) => + { + var result = await session.Events.AggregateStreamAsync(shoppingCartId, token: ct); + + return result is not null ? Ok(result) : NotFound(); + } + ); + + return app; + } +} + +public record ProductItemRequest( + Guid? ProductId, + int? Quantity +) +{ + public ProductItem ToProductItem() => new() { ProductId = ProductId.NotEmpty(), Quantity = Quantity.NotNull().Positive()}; +} + +public record AddProductRequest( + ProductItemRequest? ProductItem +); diff --git a/Workshops/IntroductionToEventSourcing/10-OptimisticConcurrency.Marten/Mixed/ShoppingCarts/Configure.cs b/Workshops/IntroductionToEventSourcing/10-OptimisticConcurrency.Marten/Mixed/ShoppingCarts/Configure.cs new file mode 100644 index 000000000..8d90d4700 --- /dev/null +++ b/Workshops/IntroductionToEventSourcing/10-OptimisticConcurrency.Marten/Mixed/ShoppingCarts/Configure.cs @@ -0,0 +1,31 @@ +using ApplicationLogic.Marten.Core.Marten; +using ApplicationLogic.Marten.Mixed.Pricing; +using Marten; + +namespace ApplicationLogic.Marten.Mixed.ShoppingCarts; +using static ShoppingCartEvent; + +public static class Configure +{ + private const string ModulePrefix = "Mixed"; + + public static IServiceCollection AddMixedShoppingCarts(this IServiceCollection services) + { + services.AddSingleton(FakeProductPriceCalculator.Returning(100)); + + return services; + } + public static StoreOptions ConfigureMixedShoppingCarts(this StoreOptions options) + { + options.Projections.LiveStreamAggregation(); + + // this is needed as we're sharing document store and have event types with the same name + options.MapEventWithPrefix(ModulePrefix); + options.MapEventWithPrefix(ModulePrefix); + options.MapEventWithPrefix(ModulePrefix); + options.MapEventWithPrefix(ModulePrefix); + options.MapEventWithPrefix(ModulePrefix); + + return options; + } +} diff --git a/Workshops/IntroductionToEventSourcing/10-OptimisticConcurrency.Marten/Mixed/BusinessLogic.cs b/Workshops/IntroductionToEventSourcing/10-OptimisticConcurrency.Marten/Mixed/ShoppingCarts/MixedShoppingCart.cs similarity index 50% rename from Workshops/IntroductionToEventSourcing/10-OptimisticConcurrency.Marten/Mixed/BusinessLogic.cs rename to Workshops/IntroductionToEventSourcing/10-OptimisticConcurrency.Marten/Mixed/ShoppingCarts/MixedShoppingCart.cs index b75141760..c16e18260 100644 --- a/Workshops/IntroductionToEventSourcing/10-OptimisticConcurrency.Marten/Mixed/BusinessLogic.cs +++ b/Workshops/IntroductionToEventSourcing/10-OptimisticConcurrency.Marten/Mixed/ShoppingCarts/MixedShoppingCart.cs @@ -1,101 +1,58 @@ -namespace IntroductionToEventSourcing.OptimisticConcurrency.Mixed; -using static ShoppingCartEvent; - -public interface IAggregate -{ - public Guid Id { get; } +using ApplicationLogic.Marten.Mixed.Pricing; - public void Evolve(object @event) { } -} +namespace ApplicationLogic.Marten.Mixed.ShoppingCarts; +using static ShoppingCartEvent; -// COMMANDS -public record OpenShoppingCart( - Guid ShoppingCartId, - Guid ClientId -) -{ - public static OpenShoppingCart From(Guid? cartId, Guid? clientId) - { - if (cartId == null || cartId == Guid.Empty) - throw new ArgumentOutOfRangeException(nameof(cartId)); - if (clientId == null || clientId == Guid.Empty) - throw new ArgumentOutOfRangeException(nameof(clientId)); - - return new OpenShoppingCart(cartId.Value, clientId.Value); - } -} - -public record AddProductItemToShoppingCart( - Guid ShoppingCartId, - ProductItem ProductItem -) -{ - public static AddProductItemToShoppingCart From(Guid? cartId, ProductItem? productItem) - { - if (cartId == null || cartId == Guid.Empty) - throw new ArgumentOutOfRangeException(nameof(cartId)); - if (productItem == null) - throw new ArgumentOutOfRangeException(nameof(productItem)); - - return new AddProductItemToShoppingCart(cartId.Value, productItem); - } -} - -public record RemoveProductItemFromShoppingCart( - Guid ShoppingCartId, - PricedProductItem ProductItem -) -{ - public static RemoveProductItemFromShoppingCart From(Guid? cartId, PricedProductItem? productItem) - { - if (cartId == null || cartId == Guid.Empty) - throw new ArgumentOutOfRangeException(nameof(cartId)); - if (productItem == null) - throw new ArgumentOutOfRangeException(nameof(productItem)); - - return new RemoveProductItemFromShoppingCart(cartId.Value, productItem); - } -} - -public record ConfirmShoppingCart( - Guid ShoppingCartId -) -{ - public static ConfirmShoppingCart From(Guid? cartId) - { - if (cartId == null || cartId == Guid.Empty) - throw new ArgumentOutOfRangeException(nameof(cartId)); - - return new ConfirmShoppingCart(cartId.Value); - } -} - -public record CancelShoppingCart( - Guid ShoppingCartId -) +// EVENTS +public abstract record ShoppingCartEvent { - public static CancelShoppingCart From(Guid? cartId) - { - if (cartId == null || cartId == Guid.Empty) - throw new ArgumentOutOfRangeException(nameof(cartId)); - - return new CancelShoppingCart(cartId.Value); - } + public record ShoppingCartOpened( + Guid ShoppingCartId, + Guid ClientId, + DateTimeOffset OpenedAt + ): ShoppingCartEvent; + + public record ProductItemAddedToShoppingCart( + Guid ShoppingCartId, + PricedProductItem ProductItem, + DateTimeOffset AddedAt + ): ShoppingCartEvent; + + public record ProductItemRemovedFromShoppingCart( + Guid ShoppingCartId, + PricedProductItem ProductItem, + DateTimeOffset RemovedAt + ): ShoppingCartEvent; + + public record ShoppingCartConfirmed( + Guid ShoppingCartId, + DateTimeOffset ConfirmedAt + ): ShoppingCartEvent; + + public record ShoppingCartCanceled( + Guid ShoppingCartId, + DateTimeOffset CanceledAt + ): ShoppingCartEvent; + + // This won't allow external inheritance + private ShoppingCartEvent() { } } -public class ShoppingCart: IAggregate +// ENTITY +// Note: We need to have prefix to be able to register multiple streams with the same name +public class MixedShoppingCart { - public Guid Id { get; private set; } + public Guid Id { get; private set; } public Guid ClientId { get; private set; } public ShoppingCartStatus Status { get; private set; } public IList ProductItems { get; } = new List(); - public DateTime? ConfirmedAt { get; private set; } - public DateTime? CanceledAt { get; private set; } + public DateTimeOffset? ConfirmedAt { get; private set; } + public DateTimeOffset? CanceledAt { get; private set; } public bool IsClosed => ShoppingCartStatus.Closed.HasFlag(Status); - public void Evolve(object @event) + public void Apply(ShoppingCartEvent @event) { switch (@event) { @@ -117,25 +74,28 @@ public void Evolve(object @event) } } - public static (ShoppingCartOpened Event, ShoppingCart Aggregate) Open( + public static (ShoppingCartOpened, MixedShoppingCart) Open( Guid cartId, - Guid clientId) + Guid clientId, + DateTimeOffset now + ) { var @event = new ShoppingCartOpened( cartId, - clientId + clientId, + now ); - - - return (@event, new ShoppingCart(@event)); + return (@event, new MixedShoppingCart(@event)); } - private ShoppingCart(ShoppingCartOpened @event) => + public static MixedShoppingCart Initial() => new(); + + private MixedShoppingCart(ShoppingCartOpened @event) => Apply(@event); //just for default creation of empty object - private ShoppingCart() { } + private MixedShoppingCart() { } private void Apply(ShoppingCartOpened opened) { @@ -146,7 +106,9 @@ private void Apply(ShoppingCartOpened opened) public ProductItemAddedToShoppingCart AddProduct( IProductPriceCalculator productPriceCalculator, - ProductItem productItem) + ProductItem productItem, + DateTimeOffset now + ) { if (IsClosed) throw new InvalidOperationException( @@ -154,7 +116,7 @@ public ProductItemAddedToShoppingCart AddProduct( var pricedProductItem = productPriceCalculator.Calculate(productItem); - var @event = new ProductItemAddedToShoppingCart(Id, pricedProductItem); + var @event = new ProductItemAddedToShoppingCart(Id, pricedProductItem, now); Apply(@event); @@ -163,7 +125,7 @@ public ProductItemAddedToShoppingCart AddProduct( private void Apply(ProductItemAddedToShoppingCart productItemAdded) { - var (_, pricedProductItem) = productItemAdded; + var pricedProductItem = productItemAdded.ProductItem; var productId = pricedProductItem.ProductId; var quantityToAdd = pricedProductItem.Quantity; @@ -174,11 +136,10 @@ private void Apply(ProductItemAddedToShoppingCart productItemAdded) if (current == null) ProductItems.Add(pricedProductItem); else - ProductItems[ProductItems.IndexOf(current)] = - new PricedProductItem(current.ProductId, current.Quantity + quantityToAdd, current.UnitPrice); + ProductItems[ProductItems.IndexOf(current)].Quantity += quantityToAdd; } - public ProductItemRemovedFromShoppingCart RemoveProduct(PricedProductItem productItemToBeRemoved) + public ProductItemRemovedFromShoppingCart RemoveProduct(PricedProductItem productItemToBeRemoved, DateTimeOffset now) { if (IsClosed) throw new InvalidOperationException( @@ -187,7 +148,7 @@ public ProductItemRemovedFromShoppingCart RemoveProduct(PricedProductItem produc if (!HasEnough(productItemToBeRemoved)) throw new InvalidOperationException("Not enough product items to remove"); - var @event = new ProductItemRemovedFromShoppingCart(Id, productItemToBeRemoved); + var @event = new ProductItemRemovedFromShoppingCart(Id, productItemToBeRemoved, now); Apply(@event); @@ -205,7 +166,7 @@ private bool HasEnough(PricedProductItem productItem) private void Apply(ProductItemRemovedFromShoppingCart productItemRemoved) { - var (_, pricedProductItem) = productItemRemoved; + var pricedProductItem = productItemRemoved.ProductItem; var productId = pricedProductItem.ProductId; var quantityToRemove = pricedProductItem.Quantity; @@ -216,17 +177,19 @@ private void Apply(ProductItemRemovedFromShoppingCart productItemRemoved) if (current.Quantity == quantityToRemove) ProductItems.Remove(current); else - ProductItems[ProductItems.IndexOf(current)] = - new PricedProductItem(current.ProductId, current.Quantity - quantityToRemove, current.UnitPrice); + ProductItems[ProductItems.IndexOf(current)].Quantity -= quantityToRemove; } - public ShoppingCartConfirmed Confirm() + public ShoppingCartConfirmed Confirm(DateTimeOffset now) { if (IsClosed) throw new InvalidOperationException( $"Confirming cart in '{Status}' status is not allowed."); - var @event = new ShoppingCartConfirmed(Id, DateTime.UtcNow); + if (ProductItems.Count == 0) + throw new InvalidOperationException($"Cannot confirm empty shopping cart"); + + var @event = new ShoppingCartConfirmed(Id, now); Apply(@event); @@ -239,13 +202,13 @@ private void Apply(ShoppingCartConfirmed confirmed) ConfirmedAt = confirmed.ConfirmedAt; } - public ShoppingCartCanceled Cancel() + public ShoppingCartCanceled Cancel(DateTimeOffset now) { if (IsClosed) throw new InvalidOperationException( $"Canceling cart in '{Status}' status is not allowed."); - var @event = new ShoppingCartCanceled(Id, DateTime.UtcNow); + var @event = new ShoppingCartCanceled(Id, now); Apply(@event); @@ -267,26 +230,3 @@ public enum ShoppingCartStatus Closed = Confirmed | Canceled } - -public interface IProductPriceCalculator -{ - PricedProductItem Calculate(ProductItem productItems); -} - -public class FakeProductPriceCalculator: IProductPriceCalculator -{ - private readonly int value; - - private FakeProductPriceCalculator(int value) - { - this.value = value; - } - - public static FakeProductPriceCalculator Returning(int value) => new(value); - - public PricedProductItem Calculate(ProductItem productItem) - { - var (productId, quantity) = productItem; - return new PricedProductItem(productId, quantity, value); - } -} diff --git a/Workshops/IntroductionToEventSourcing/10-OptimisticConcurrency.Marten/Mutable/DocumentSessionExtensions.cs b/Workshops/IntroductionToEventSourcing/10-OptimisticConcurrency.Marten/Mutable/DocumentSessionExtensions.cs deleted file mode 100644 index 1dcc1166b..000000000 --- a/Workshops/IntroductionToEventSourcing/10-OptimisticConcurrency.Marten/Mutable/DocumentSessionExtensions.cs +++ /dev/null @@ -1,41 +0,0 @@ -using Marten; - -namespace IntroductionToEventSourcing.OptimisticConcurrency.Mutable; - -public static class DocumentSessionExtensions -{ - public static Task Get( - this IDocumentSession session, - Guid id, - CancellationToken cancellationToken = default - ) where TAggregate : Aggregate - { - // Fill logic here. - throw new NotImplementedException(); - } - - public static Task Add( - this IDocumentSession session, - Func getId, - Func action, - TCommand command, - CancellationToken cancellationToken = default - ) where TAggregate : Aggregate - { - // Fill logic here. - throw new NotImplementedException(); - } - - public static Task GetAndUpdate( - this IDocumentSession session, - Func getId, - Action action, - TCommand command, - long version, - CancellationToken cancellationToken = default - ) where TAggregate : Aggregate - { - // Fill logic here. - throw new NotImplementedException(); - } -} diff --git a/Workshops/IntroductionToEventSourcing/10-OptimisticConcurrency.Marten/Mutable/OptimisticConcurrencyTests.cs b/Workshops/IntroductionToEventSourcing/10-OptimisticConcurrency.Marten/Mutable/OptimisticConcurrencyTests.cs deleted file mode 100644 index 758b4e83b..000000000 --- a/Workshops/IntroductionToEventSourcing/10-OptimisticConcurrency.Marten/Mutable/OptimisticConcurrencyTests.cs +++ /dev/null @@ -1,135 +0,0 @@ -using FluentAssertions; -using IntroductionToEventSourcing.OptimisticConcurrency.Tools; -using Marten.Exceptions; -using Xunit; - -namespace IntroductionToEventSourcing.OptimisticConcurrency.Mutable; - -// EVENTS -public abstract record ShoppingCartEvent -{ - public record ShoppingCartOpened( - Guid ShoppingCartId, - Guid ClientId - ): ShoppingCartEvent; - - public record ProductItemAddedToShoppingCart( - Guid ShoppingCartId, - PricedProductItem ProductItem - ): ShoppingCartEvent; - - public record ProductItemRemovedFromShoppingCart( - Guid ShoppingCartId, - PricedProductItem ProductItem - ): ShoppingCartEvent; - - public record ShoppingCartConfirmed( - Guid ShoppingCartId, - DateTime ConfirmedAt - ): ShoppingCartEvent; - - public record ShoppingCartCanceled( - Guid ShoppingCartId, - DateTime CanceledAt - ): ShoppingCartEvent; - - // This won't allow external inheritance - private ShoppingCartEvent(){} -} - -// VALUE OBJECTS -public class PricedProductItem -{ - public Guid ProductId { get; set; } - public decimal UnitPrice { get; set; } - public int Quantity { get; set; } - public decimal TotalPrice => Quantity * UnitPrice; -} - -public class ProductItem -{ - public Guid ProductId { get; set; } - public int Quantity { get; set; } -} - -public class OptimisticConcurrencyTests: MartenTest -{ - [Fact] - [Trait("Category", "SkipCI")] - public async Task GettingState_ForSequenceOfEvents_ShouldSucceed() - { - var shoppingCartId = Guid.NewGuid(); - var clientId = Guid.NewGuid(); - var shoesId = Guid.NewGuid(); - var tShirtId = Guid.NewGuid(); - - var twoPairsOfShoes = new ProductItem { ProductId = shoesId, Quantity = 2 }; - var tShirt = new ProductItem { ProductId = tShirtId, Quantity = 1 }; - - var shoesPrice = 100; - var tShirtPrice = 50; - - // Open - await DocumentSession.Add( - command => command.ShoppingCartId, - command => - ShoppingCart.Open(command.ShoppingCartId, command.ClientId), - OpenShoppingCart.From(shoppingCartId, clientId), - CancellationToken.None - ); - - // Try to open again - // Should fail as stream was already created - var exception = await Record.ExceptionAsync(async () => - { - await DocumentSession.Add( - command => command.ShoppingCartId, - command => - ShoppingCart.Open(command.ShoppingCartId, command.ClientId), - OpenShoppingCart.From(shoppingCartId, clientId), - CancellationToken.None - ); - } - ); - exception.Should().BeOfType(); - ReOpenSession(); - - // Add two pairs of shoes - await DocumentSession.GetAndUpdate( - command => command.ShoppingCartId, - (command, shoppingCart) => - shoppingCart.AddProduct(FakeProductPriceCalculator.Returning(shoesPrice), command.ProductItem), - AddProductItemToShoppingCart.From(shoppingCartId, twoPairsOfShoes), - 1, - CancellationToken.None - ); - - // Add T-Shirt - // Should fail because of sending the same expected version as previous call - exception = await Record.ExceptionAsync(async () => - { - await DocumentSession.GetAndUpdate( - command => command.ShoppingCartId, - (command, shoppingCart) => - shoppingCart.AddProduct(FakeProductPriceCalculator.Returning(tShirtPrice), command.ProductItem), - AddProductItemToShoppingCart.From(shoppingCartId, tShirt), - 1, - CancellationToken.None - ); - } - ); - exception.Should().BeOfType(); - ReOpenSession(); - - var shoppingCart = await DocumentSession.Get(shoppingCartId, CancellationToken.None); - - shoppingCart.Id.Should().Be(shoppingCartId); - shoppingCart.ClientId.Should().Be(clientId); - shoppingCart.ProductItems.Should().HaveCount(1); - shoppingCart.Status.Should().Be(ShoppingCartStatus.Pending); - - shoppingCart.ProductItems[0].ProductId.Should().Be(shoesId); - shoppingCart.ProductItems[0].Quantity.Should().Be(twoPairsOfShoes.Quantity); - shoppingCart.ProductItems[0].UnitPrice.Should().Be(shoesPrice); - } -} diff --git a/Workshops/IntroductionToEventSourcing/10-OptimisticConcurrency.Marten/Mutable/Pricing/PricingCalculator.cs b/Workshops/IntroductionToEventSourcing/10-OptimisticConcurrency.Marten/Mutable/Pricing/PricingCalculator.cs new file mode 100644 index 000000000..c459f7bb4 --- /dev/null +++ b/Workshops/IntroductionToEventSourcing/10-OptimisticConcurrency.Marten/Mutable/Pricing/PricingCalculator.cs @@ -0,0 +1,21 @@ +namespace ApplicationLogic.Marten.Mutable.Pricing; + +public interface IProductPriceCalculator +{ + PricedProductItem Calculate(ProductItem productItems); +} + +public class FakeProductPriceCalculator: IProductPriceCalculator +{ + private readonly int value; + + private FakeProductPriceCalculator(int value) + { + this.value = value; + } + + public static FakeProductPriceCalculator Returning(int value) => new(value); + + public PricedProductItem Calculate(ProductItem productItem) => + new() { ProductId = productItem.ProductId, Quantity = productItem.Quantity, UnitPrice = value }; +} diff --git a/Workshops/IntroductionToEventSourcing/10-OptimisticConcurrency.Marten/Mutable/Pricing/ProductItems.cs b/Workshops/IntroductionToEventSourcing/10-OptimisticConcurrency.Marten/Mutable/Pricing/ProductItems.cs new file mode 100644 index 000000000..8c98934c7 --- /dev/null +++ b/Workshops/IntroductionToEventSourcing/10-OptimisticConcurrency.Marten/Mutable/Pricing/ProductItems.cs @@ -0,0 +1,16 @@ +namespace ApplicationLogic.Marten.Mutable.Pricing; + +public class PricedProductItem +{ + public Guid ProductId { get; set; } + public decimal UnitPrice { get; set; } + public int Quantity { get; set; } + public decimal TotalPrice => Quantity * UnitPrice; +} + +public class ProductItem +{ + public Guid ProductId { get; set; } + public int Quantity { get; set; } +} + diff --git a/Workshops/IntroductionToEventSourcing/10-OptimisticConcurrency.Marten/Mutable/ShoppingCarts/Api.cs b/Workshops/IntroductionToEventSourcing/10-OptimisticConcurrency.Marten/Mutable/ShoppingCarts/Api.cs new file mode 100644 index 000000000..be7b18493 --- /dev/null +++ b/Workshops/IntroductionToEventSourcing/10-OptimisticConcurrency.Marten/Mutable/ShoppingCarts/Api.cs @@ -0,0 +1,126 @@ +using ApplicationLogic.Marten.Core.Marten; +using ApplicationLogic.Marten.Mutable.Pricing; +using Core.Validation; +using Marten; +using Marten.Schema.Identity; +using Microsoft.AspNetCore.Mvc; +using static Microsoft.AspNetCore.Http.TypedResults; +using static System.DateTimeOffset; + +namespace ApplicationLogic.Marten.Mutable.ShoppingCarts; + +public static class Api +{ + public static WebApplication ConfigureMutableShoppingCarts(this WebApplication app) + { + var clients = app.MapGroup("/api/mutable/clients/{clientId:guid}/"); + var shoppingCarts = clients.MapGroup("shopping-carts"); + var shoppingCart = shoppingCarts.MapGroup("{shoppingCartId:guid}"); + var productItems = shoppingCart.MapGroup("product-items"); + + shoppingCarts.MapPost("", + async (IDocumentSession session, + Guid clientId, + CancellationToken ct) => + { + var shoppingCartId = CombGuidIdGeneration.NewGuid(); + + await session.Add(shoppingCartId, + MutableShoppingCart.Open(shoppingCartId, clientId.NotEmpty(), Now).DequeueUncommittedEvents(), ct); + + return Created($"/api/mutable/clients/{clientId}/shopping-carts/{shoppingCartId}", shoppingCartId); + } + ); + + productItems.MapPost("", + async ( + IProductPriceCalculator pricingCalculator, + IDocumentSession session, + Guid shoppingCartId, + AddProductRequest body, + CancellationToken ct) => + { + var productItem = body.ProductItem.NotNull().ToProductItem(); + + await session.GetAndUpdate(shoppingCartId, + state => state.AddProduct(pricingCalculator, productItem, Now), ct); + + return NoContent(); + } + ); + + productItems.MapDelete("{productId:guid}", + async ( + IDocumentSession session, + Guid shoppingCartId, + [FromRoute] Guid productId, + [FromQuery] int? quantity, + [FromQuery] decimal? unitPrice, + CancellationToken ct) => + { + var productItem = new PricedProductItem + { + ProductId = productId.NotEmpty(), + Quantity = quantity.NotNull().Positive(), + UnitPrice = unitPrice.NotNull().Positive() + }; + + await session.GetAndUpdate(shoppingCartId, + state => state.RemoveProduct(productItem, Now), + ct); + + return NoContent(); + } + ); + + shoppingCart.MapPost("confirm", + async (IDocumentSession session, + Guid shoppingCartId, + CancellationToken ct) => + { + await session.GetAndUpdate(shoppingCartId, + state => state.Confirm(Now), ct); + + return NoContent(); + } + ); + + shoppingCart.MapDelete("", + async (IDocumentSession session, + Guid shoppingCartId, + CancellationToken ct) => + { + await session.GetAndUpdate(shoppingCartId, + state => state.Cancel(Now), ct); + + return NoContent(); + } + ); + + shoppingCart.MapGet("", + async Task ( + IQuerySession session, + Guid shoppingCartId, + CancellationToken ct) => + { + var result = await session.Events.AggregateStreamAsync(shoppingCartId, token: ct); + + return result is not null ? Ok(result) : NotFound(); + } + ); + + return app; + } +} + +public record ProductItemRequest( + Guid? ProductId, + int? Quantity +) +{ + public ProductItem ToProductItem() => new() { ProductId = ProductId.NotEmpty(), Quantity = Quantity.NotNull().Positive()}; +} + +public record AddProductRequest( + ProductItemRequest? ProductItem +); diff --git a/Workshops/IntroductionToEventSourcing/10-OptimisticConcurrency.Marten/Mutable/ShoppingCarts/Configure.cs b/Workshops/IntroductionToEventSourcing/10-OptimisticConcurrency.Marten/Mutable/ShoppingCarts/Configure.cs new file mode 100644 index 000000000..0632d3777 --- /dev/null +++ b/Workshops/IntroductionToEventSourcing/10-OptimisticConcurrency.Marten/Mutable/ShoppingCarts/Configure.cs @@ -0,0 +1,31 @@ +using ApplicationLogic.Marten.Core.Marten; +using ApplicationLogic.Marten.Mutable.Pricing; +using Marten; + +namespace ApplicationLogic.Marten.Mutable.ShoppingCarts; +using static ShoppingCartEvent; + +public static class Configure +{ + private const string ModulePrefix = "mutable"; + + public static IServiceCollection AddMutableShoppingCarts(this IServiceCollection services) + { + services.AddSingleton(FakeProductPriceCalculator.Returning(100)); + + return services; + } + public static StoreOptions ConfigureMutableShoppingCarts(this StoreOptions options) + { + options.Projections.LiveStreamAggregation(); + + // this is needed as we're sharing document store and have event types with the same name + options.MapEventWithPrefix(ModulePrefix); + options.MapEventWithPrefix(ModulePrefix); + options.MapEventWithPrefix(ModulePrefix); + options.MapEventWithPrefix(ModulePrefix); + options.MapEventWithPrefix(ModulePrefix); + + return options; + } +} diff --git a/Workshops/IntroductionToEventSourcing/10-OptimisticConcurrency.Marten/Mutable/BusinessLogic.cs b/Workshops/IntroductionToEventSourcing/10-OptimisticConcurrency.Marten/Mutable/ShoppingCarts/MutableShoppingCart.cs similarity index 51% rename from Workshops/IntroductionToEventSourcing/10-OptimisticConcurrency.Marten/Mutable/BusinessLogic.cs rename to Workshops/IntroductionToEventSourcing/10-OptimisticConcurrency.Marten/Mutable/ShoppingCarts/MutableShoppingCart.cs index 6966a732e..24f8814cd 100644 --- a/Workshops/IntroductionToEventSourcing/10-OptimisticConcurrency.Marten/Mutable/BusinessLogic.cs +++ b/Workshops/IntroductionToEventSourcing/10-OptimisticConcurrency.Marten/Mutable/ShoppingCarts/MutableShoppingCart.cs @@ -1,115 +1,58 @@ -namespace IntroductionToEventSourcing.OptimisticConcurrency.Mutable; -using static ShoppingCartEvent; - -public abstract class Aggregate -{ - public Guid Id { get; protected set; } = default!; - - private readonly Queue uncommittedEvents = new(); - - public virtual void Evolve(object @event) { } - - public object[] DequeueUncommittedEvents() - { - var dequeuedEvents = uncommittedEvents.ToArray(); - - uncommittedEvents.Clear(); - - return dequeuedEvents; - } - - protected void Enqueue(object @event) - { - uncommittedEvents.Enqueue(@event); - } -} +using ApplicationLogic.Marten.Core.Entities; +using ApplicationLogic.Marten.Mutable.Pricing; -// COMMANDS -public record OpenShoppingCart( - Guid ShoppingCartId, - Guid ClientId -) -{ - public static OpenShoppingCart From(Guid? cartId, Guid? clientId) - { - if (cartId == null || cartId == Guid.Empty) - throw new ArgumentOutOfRangeException(nameof(cartId)); - if (clientId == null || clientId == Guid.Empty) - throw new ArgumentOutOfRangeException(nameof(clientId)); - - return new OpenShoppingCart(cartId.Value, clientId.Value); - } -} - -public record AddProductItemToShoppingCart( - Guid ShoppingCartId, - ProductItem ProductItem -) -{ - public static AddProductItemToShoppingCart From(Guid? cartId, ProductItem? productItem) - { - if (cartId == null || cartId == Guid.Empty) - throw new ArgumentOutOfRangeException(nameof(cartId)); - if (productItem == null) - throw new ArgumentOutOfRangeException(nameof(productItem)); - - return new AddProductItemToShoppingCart(cartId.Value, productItem); - } -} - -public record RemoveProductItemFromShoppingCart( - Guid ShoppingCartId, - PricedProductItem ProductItem -) -{ - public static RemoveProductItemFromShoppingCart From(Guid? cartId, PricedProductItem? productItem) - { - if (cartId == null || cartId == Guid.Empty) - throw new ArgumentOutOfRangeException(nameof(cartId)); - if (productItem == null) - throw new ArgumentOutOfRangeException(nameof(productItem)); +namespace ApplicationLogic.Marten.Mutable.ShoppingCarts; - return new RemoveProductItemFromShoppingCart(cartId.Value, productItem); - } -} - -public record ConfirmShoppingCart( - Guid ShoppingCartId -) -{ - public static ConfirmShoppingCart From(Guid? cartId) - { - if (cartId == null || cartId == Guid.Empty) - throw new ArgumentOutOfRangeException(nameof(cartId)); - - return new ConfirmShoppingCart(cartId.Value); - } -} +using static ShoppingCartEvent; -public record CancelShoppingCart( - Guid ShoppingCartId -) +// EVENTS +public abstract record ShoppingCartEvent { - public static CancelShoppingCart From(Guid? cartId) - { - if (cartId == null || cartId == Guid.Empty) - throw new ArgumentOutOfRangeException(nameof(cartId)); - - return new CancelShoppingCart(cartId.Value); - } + public record ShoppingCartOpened( + Guid ShoppingCartId, + Guid ClientId, + DateTimeOffset OpenedAt + ): ShoppingCartEvent; + + public record ProductItemAddedToShoppingCart( + Guid ShoppingCartId, + PricedProductItem ProductItem, + DateTimeOffset AddedAt + ): ShoppingCartEvent; + + public record ProductItemRemovedFromShoppingCart( + Guid ShoppingCartId, + PricedProductItem ProductItem, + DateTimeOffset RemovedAt + ): ShoppingCartEvent; + + public record ShoppingCartConfirmed( + Guid ShoppingCartId, + DateTimeOffset ConfirmedAt + ): ShoppingCartEvent; + + public record ShoppingCartCanceled( + Guid ShoppingCartId, + DateTimeOffset CanceledAt + ): ShoppingCartEvent; + + // This won't allow external inheritance + private ShoppingCartEvent() { } } -public class ShoppingCart: Aggregate +// ENTITY +// Note: We need to have prefix to be able to register multiple streams with the same name +public class MutableShoppingCart: Aggregate { public Guid ClientId { get; private set; } public ShoppingCartStatus Status { get; private set; } public IList ProductItems { get; } = new List(); - public DateTime? ConfirmedAt { get; private set; } - public DateTime? CanceledAt { get; private set; } + public DateTimeOffset? ConfirmedAt { get; private set; } + public DateTimeOffset? CanceledAt { get; private set; } public bool IsClosed => ShoppingCartStatus.Closed.HasFlag(Status); - public override void Evolve(object @event) + protected override void Apply(ShoppingCartEvent @event) { switch (@event) { @@ -131,20 +74,27 @@ public override void Evolve(object @event) } } - public static ShoppingCart Open( + public static MutableShoppingCart Open( Guid cartId, - Guid clientId) + Guid clientId, + DateTimeOffset now + ) { - return new ShoppingCart(cartId, clientId); + return new MutableShoppingCart(cartId, clientId, now); } - private ShoppingCart( + public static MutableShoppingCart Initial() => new(); + + private MutableShoppingCart( Guid id, - Guid clientId) + Guid clientId, + DateTimeOffset now + ) { var @event = new ShoppingCartOpened( id, - clientId + clientId, + now ); Enqueue(@event); @@ -152,7 +102,7 @@ private ShoppingCart( } //just for default creation of empty object - private ShoppingCart() { } + private MutableShoppingCart() { } private void Apply(ShoppingCartOpened opened) { @@ -163,7 +113,9 @@ private void Apply(ShoppingCartOpened opened) public void AddProduct( IProductPriceCalculator productPriceCalculator, - ProductItem productItem) + ProductItem productItem, + DateTimeOffset now + ) { if (IsClosed) throw new InvalidOperationException( @@ -171,7 +123,7 @@ public void AddProduct( var pricedProductItem = productPriceCalculator.Calculate(productItem); - var @event = new ProductItemAddedToShoppingCart(Id, pricedProductItem); + var @event = new ProductItemAddedToShoppingCart(Id, pricedProductItem, now); Enqueue(@event); Apply(@event); @@ -179,7 +131,7 @@ public void AddProduct( private void Apply(ProductItemAddedToShoppingCart productItemAdded) { - var (_, pricedProductItem) = productItemAdded; + var pricedProductItem = productItemAdded.ProductItem; var productId = pricedProductItem.ProductId; var quantityToAdd = pricedProductItem.Quantity; @@ -193,7 +145,7 @@ private void Apply(ProductItemAddedToShoppingCart productItemAdded) current.Quantity += quantityToAdd; } - public void RemoveProduct(PricedProductItem productItemToBeRemoved) + public void RemoveProduct(PricedProductItem productItemToBeRemoved, DateTimeOffset now) { if (IsClosed) throw new InvalidOperationException( @@ -202,7 +154,7 @@ public void RemoveProduct(PricedProductItem productItemToBeRemoved) if (!HasEnough(productItemToBeRemoved)) throw new InvalidOperationException("Not enough product items to remove"); - var @event = new ProductItemRemovedFromShoppingCart(Id, productItemToBeRemoved); + var @event = new ProductItemRemovedFromShoppingCart(Id, productItemToBeRemoved, now); Enqueue(@event); Apply(@event); @@ -219,7 +171,7 @@ private bool HasEnough(PricedProductItem productItem) private void Apply(ProductItemRemovedFromShoppingCart productItemRemoved) { - var (_, pricedProductItem) = productItemRemoved; + var pricedProductItem = productItemRemoved.ProductItem; var productId = pricedProductItem.ProductId; var quantityToRemove = pricedProductItem.Quantity; @@ -233,13 +185,16 @@ private void Apply(ProductItemRemovedFromShoppingCart productItemRemoved) current.Quantity -= quantityToRemove; } - public void Confirm() + public void Confirm(DateTimeOffset now) { if (IsClosed) throw new InvalidOperationException( $"Confirming cart in '{Status}' status is not allowed."); - var @event = new ShoppingCartConfirmed(Id, DateTime.UtcNow); + if (ProductItems.Count == 0) + throw new InvalidOperationException($"Cannot confirm empty shopping cart"); + + var @event = new ShoppingCartConfirmed(Id, now); Enqueue(@event); Apply(@event); @@ -251,13 +206,13 @@ private void Apply(ShoppingCartConfirmed confirmed) ConfirmedAt = confirmed.ConfirmedAt; } - public void Cancel() + public void Cancel(DateTimeOffset now) { if (IsClosed) throw new InvalidOperationException( $"Canceling cart in '{Status}' status is not allowed."); - var @event = new ShoppingCartCanceled(Id, DateTime.UtcNow); + var @event = new ShoppingCartCanceled(Id, now); Enqueue(@event); Apply(@event); @@ -278,30 +233,3 @@ public enum ShoppingCartStatus Closed = Confirmed | Canceled } - -public interface IProductPriceCalculator -{ - PricedProductItem Calculate(ProductItem productItems); -} - -public class FakeProductPriceCalculator: IProductPriceCalculator -{ - private readonly int value; - - private FakeProductPriceCalculator(int value) - { - this.value = value; - } - - public static FakeProductPriceCalculator Returning(int value) => new(value); - - public PricedProductItem Calculate(ProductItem productItem) - { - return new PricedProductItem - { - ProductId = productItem.ProductId, - Quantity = productItem.Quantity, - UnitPrice = value - }; - } -} diff --git a/Workshops/IntroductionToEventSourcing/10-OptimisticConcurrency.Marten/Program.cs b/Workshops/IntroductionToEventSourcing/10-OptimisticConcurrency.Marten/Program.cs new file mode 100644 index 000000000..79a6ef903 --- /dev/null +++ b/Workshops/IntroductionToEventSourcing/10-OptimisticConcurrency.Marten/Program.cs @@ -0,0 +1,71 @@ +using ApplicationLogic.Marten.Core.Exceptions; +using ApplicationLogic.Marten.Immutable.ShoppingCarts; +using ApplicationLogic.Marten.Mixed.ShoppingCarts; +using ApplicationLogic.Marten.Mutable.ShoppingCarts; +using Marten; +using Microsoft.AspNetCore.Diagnostics; +using Oakton; + +var builder = WebApplication.CreateBuilder(args); + +builder.Services + .AddRouting() + .AddEndpointsApiExplorer() + .AddSwaggerGen() + .AddMutableShoppingCarts() + .AddMixedShoppingCarts() + .AddImmutableShoppingCarts() + .AddMarten(options => + { + var schemaName = Environment.GetEnvironmentVariable("SchemaName") ?? "Workshop_Optimistic_ShoppingCarts"; + options.Events.DatabaseSchemaName = schemaName; + options.DatabaseSchemaName = schemaName; + options.Connection(builder.Configuration.GetConnectionString("ShoppingCarts") ?? + throw new InvalidOperationException()); + + options.ConfigureImmutableShoppingCarts() + .ConfigureMutableShoppingCarts() + .ConfigureMixedShoppingCarts(); + }) + .OptimizeArtifactWorkflow() + .UseLightweightSessions(); + +builder.Host.ApplyOaktonExtensions(); + +var app = builder.Build(); + +app.UseExceptionHandler(new ExceptionHandlerOptions + { + AllowStatusCode404Response = true, + ExceptionHandler = context => + { + var exception = context.Features.Get()?.Error; + + context.Response.StatusCode = exception switch + { + ArgumentException => StatusCodes.Status400BadRequest, + NotFoundException => StatusCodes.Status404NotFound, + InvalidOperationException => StatusCodes.Status409Conflict, + _ => StatusCodes.Status500InternalServerError, + }; + + return Task.CompletedTask; + } + }) + .UseRouting(); + +app.ConfigureImmutableShoppingCarts() + .ConfigureMutableShoppingCarts() + .ConfigureMixedShoppingCarts(); + +if (app.Environment.IsDevelopment()) +{ + app.UseSwagger() + .UseSwaggerUI(); +} + +return await app.RunOaktonCommands(args); + +public partial class Program +{ +} diff --git a/Workshops/IntroductionToEventSourcing/10-OptimisticConcurrency.Marten/Properties/launchSettings.json b/Workshops/IntroductionToEventSourcing/10-OptimisticConcurrency.Marten/Properties/launchSettings.json new file mode 100644 index 000000000..01dd421e9 --- /dev/null +++ b/Workshops/IntroductionToEventSourcing/10-OptimisticConcurrency.Marten/Properties/launchSettings.json @@ -0,0 +1,14 @@ +{ + "profiles": { + "Helpdesk.Api": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": true, + "launchUrl": "swagger/index.html", + "applicationUrl": "http://localhost:5248", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + } + } +} diff --git a/Workshops/IntroductionToEventSourcing/10-OptimisticConcurrency.Marten/README.md b/Workshops/IntroductionToEventSourcing/10-OptimisticConcurrency.Marten/README.md deleted file mode 100644 index 02ddb0e8e..000000000 --- a/Workshops/IntroductionToEventSourcing/10-OptimisticConcurrency.Marten/README.md +++ /dev/null @@ -1,30 +0,0 @@ -# Exercise 10 - Optimistic Concurrency with Marten - -Having the following shopping cart process and implementation from previous exercises: -1. The customer may add a product to the shopping cart only after opening it. -2. When selecting and adding a product to the basket customer needs to provide the quantity chosen. The product price is calculated by the system based on the current price list. -3. The customer may remove a product with a given price from the cart. -4. The customer can confirm the shopping cart and start the order fulfilment process. -5. The customer may also cancel the shopping cart and reject all selected products. -6. After shopping cart confirmation or cancellation, the product can no longer be added or removed from the cart. - -How will the solution change when we add requirements: -1. We can add up to 10 products marked as "Super discount!". -2. The customer can only have one open cart at a time. - -How will we ensure data consistency when, for example, the wife and husband have access to the account, who at the same time tried to open the basket, or the husband confirmed and the wife tried to add another purchase? - -Extend your implementation to include these rules. - -![events](./assets/events.jpg) - -There are two variations: -1. Immutable, with functional command handlers composition and entities as anemic data model: [Immutable/OptimisticConcurrencyTests.cs](./Immutable/OptimisticConcurrencyTests.cs). -2. Classical, mutable aggregates (rich domain model): [Mutable/OptimisticConcurrencyTests.cs](./Mutable/OptimisticConcurrencyTests.cs). -3. Mixed approach, mutable aggregates (rich domain model), returning events from methods, using immutable DTOs: [Mixed/OptimisticConcurrencyTests.cs](./Mixed/OptimisticConcurrencyTests.cs). - -Select your preferred approach (or all) to solve this use case using Marten. Fill appropriate `DocumentSessionExtensions` - -_**Note**: If needed update entities, events or test setup structure_ - - diff --git a/Workshops/IntroductionToEventSourcing/10-OptimisticConcurrency.Marten/Tools/MartenTest.cs b/Workshops/IntroductionToEventSourcing/10-OptimisticConcurrency.Marten/Tools/MartenTest.cs deleted file mode 100644 index 7d8c2dd9e..000000000 --- a/Workshops/IntroductionToEventSourcing/10-OptimisticConcurrency.Marten/Tools/MartenTest.cs +++ /dev/null @@ -1,41 +0,0 @@ -using Marten; - -namespace IntroductionToEventSourcing.OptimisticConcurrency.Tools; - -public abstract class MartenTest: IDisposable -{ - private readonly DocumentStore documentStore; - protected IDocumentSession DocumentSession = default!; - - protected MartenTest() - { - var options = new StoreOptions(); - options.Connection( - "PORT = 5432; HOST = localhost; TIMEOUT = 15; POOLING = True; DATABASE = 'postgres'; PASSWORD = 'Password12!'; USER ID = 'postgres'"); - options.UseDefaultSerialization(nonPublicMembersStorage: NonPublicMembersStorage.All); - options.DatabaseSchemaName = options.Events.DatabaseSchemaName = "IntroductionToEventSourcing"; - - documentStore = new DocumentStore(options); - ReOpenSession(); - } - - protected void ReOpenSession() - { - DocumentSession = documentStore.LightweightSession(); - } - - protected Task AppendEvents(Guid streamId, object[] events, CancellationToken ct) - { - DocumentSession.Events.Append( - streamId, - events - ); - return DocumentSession.SaveChangesAsync(ct); - } - - public virtual void Dispose() - { - DocumentSession.Dispose(); - documentStore.Dispose(); - } -} diff --git a/Workshops/IntroductionToEventSourcing/10-OptimisticConcurrency.Marten/appsettings.json b/Workshops/IntroductionToEventSourcing/10-OptimisticConcurrency.Marten/appsettings.json new file mode 100644 index 000000000..26bccde8b --- /dev/null +++ b/Workshops/IntroductionToEventSourcing/10-OptimisticConcurrency.Marten/appsettings.json @@ -0,0 +1,18 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + }, + "AllowedHosts": "*", + "ConnectionStrings": { + "ShoppingCarts": "PORT = 5432; HOST = localhost; TIMEOUT = 15; POOLING = True; MINPOOLSIZE = 1; MAXPOOLSIZE = 100; COMMANDTIMEOUT = 20; DATABASE = 'postgres'; PASSWORD = 'Password12!'; USER ID = 'postgres'" + }, + "KafkaProducer": { + "ProducerConfig": { + "BootstrapServers": "localhost:9092" + }, + "Topic": "Incidents" + } +} diff --git a/Workshops/IntroductionToEventSourcing/10-OptimisticConcurrency.Marten/assets/events.jpg b/Workshops/IntroductionToEventSourcing/10-OptimisticConcurrency.Marten/assets/events.jpg deleted file mode 100644 index 35522f580..000000000 Binary files a/Workshops/IntroductionToEventSourcing/10-OptimisticConcurrency.Marten/assets/events.jpg and /dev/null differ diff --git a/Workshops/IntroductionToEventSourcing/Solved/08-ApplicationLogic.Marten/Core/Http/ETagExtensions.cs b/Workshops/IntroductionToEventSourcing/Solved/08-ApplicationLogic.Marten/Core/Http/ETagExtensions.cs deleted file mode 100644 index 51bf3a7a7..000000000 --- a/Workshops/IntroductionToEventSourcing/Solved/08-ApplicationLogic.Marten/Core/Http/ETagExtensions.cs +++ /dev/null @@ -1,29 +0,0 @@ -using Microsoft.AspNetCore.Mvc; -using Microsoft.Net.Http.Headers; - -namespace ApplicationLogic.Marten.Core.Http; - -public static class ETagExtensions -{ - public static int ToExpectedVersion(string? eTag) - { - if (eTag is null) - throw new ArgumentNullException(nameof(eTag)); - - var value = EntityTagHeaderValue.Parse(eTag).Tag.Value; - - if (value is null) - throw new ArgumentNullException(nameof(eTag)); - - return int.Parse(value.Substring(1, value.Length - 2)); - } -} - -[AttributeUsage(AttributeTargets.Parameter | AttributeTargets.Property, AllowMultiple = false, Inherited = true)] -public class FromIfMatchHeaderAttribute: FromHeaderAttribute -{ - public FromIfMatchHeaderAttribute() - { - Name = "If-Match"; - } -} diff --git a/Workshops/IntroductionToEventSourcing/Solved/08-ApplicationLogic.Marten/Program.cs b/Workshops/IntroductionToEventSourcing/Solved/08-ApplicationLogic.Marten/Program.cs index 949cb73fb..ecb38b84c 100644 --- a/Workshops/IntroductionToEventSourcing/Solved/08-ApplicationLogic.Marten/Program.cs +++ b/Workshops/IntroductionToEventSourcing/Solved/08-ApplicationLogic.Marten/Program.cs @@ -17,7 +17,7 @@ .AddImmutableShoppingCarts() .AddMarten(options => { - var schemaName = Environment.GetEnvironmentVariable("SchemaName") ?? "Workshop_ShoppingCarts"; + var schemaName = Environment.GetEnvironmentVariable("SchemaName") ?? "Workshop_Application_ShoppingCarts"; options.Events.DatabaseSchemaName = schemaName; options.DatabaseSchemaName = schemaName; options.Connection(builder.Configuration.GetConnectionString("ShoppingCarts") ??