diff --git a/EventSourcingEcommerce.sln b/EventSourcingEcommerce.sln index 3b429ee..6d9dd8c 100644 --- a/EventSourcingEcommerce.sln +++ b/EventSourcingEcommerce.sln @@ -61,6 +61,8 @@ Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Retail", "Retail", "{91DC34 EndProject Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "shopping-cart", "shopping-cart", "{E8D6C05C-F653-42AA-BB9B-6E57251C7E99}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Ecommerce.Eventuous", "src\Core\Ecommerce.Eventuous\Ecommerce.Eventuous.csproj", "{3C54979E-C2BB-448E-86EE-08F366B973DF}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -135,6 +137,10 @@ Global {F42EAACF-D74A-4646-A311-AB9EB131AE8A}.Debug|Any CPU.Build.0 = Debug|Any CPU {F42EAACF-D74A-4646-A311-AB9EB131AE8A}.Release|Any CPU.ActiveCfg = Release|Any CPU {F42EAACF-D74A-4646-A311-AB9EB131AE8A}.Release|Any CPU.Build.0 = Release|Any CPU + {3C54979E-C2BB-448E-86EE-08F366B973DF}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {3C54979E-C2BB-448E-86EE-08F366B973DF}.Debug|Any CPU.Build.0 = Debug|Any CPU + {3C54979E-C2BB-448E-86EE-08F366B973DF}.Release|Any CPU.ActiveCfg = Release|Any CPU + {3C54979E-C2BB-448E-86EE-08F366B973DF}.Release|Any CPU.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(NestedProjects) = preSolution {9CD0617F-6555-4F29-9C3E-211DC9A92CD5} = {5C748417-563A-41A4-AC92-67A815C5A929} @@ -155,5 +161,6 @@ Global {090F1820-19D9-464B-A0B0-415182075D9D} = {E8FE7001-AFB6-4FB2-B922-BB6A203A652E} {F42EAACF-D74A-4646-A311-AB9EB131AE8A} = {E8FE7001-AFB6-4FB2-B922-BB6A203A652E} {E8D6C05C-F653-42AA-BB9B-6E57251C7E99} = {91DC34A2-E5DC-4BDD-9BAE-6B9D1E8E0769} + {3C54979E-C2BB-448E-86EE-08F366B973DF} = {5C748417-563A-41A4-AC92-67A815C5A929} EndGlobalSection EndGlobal diff --git a/src/Catalog/Catalog.Api/Commands/ProductCommandService.cs b/src/Catalog/Catalog.Api/Commands/ProductCommandService.cs index 26e5bea..60676eb 100644 --- a/src/Catalog/Catalog.Api/Commands/ProductCommandService.cs +++ b/src/Catalog/Catalog.Api/Commands/ProductCommandService.cs @@ -23,6 +23,7 @@ public ProductCommandService( cmd.Sku, cmd.Name, cmd.Description, + cmd.Brand, DateTimeOffset.Now, cmd.CreatedBy, isSkuAvailable, @@ -35,6 +36,7 @@ public ProductCommandService( cmd.Sku, cmd.Name, cmd.Description, + cmd.Brand, DateTimeOffset.Now, cmd.CreatedBy, isSkuAvailable, diff --git a/src/Catalog/Catalog.Api/Commands/ProductCommands.cs b/src/Catalog/Catalog.Api/Commands/ProductCommands.cs index 31612e6..b94e4e0 100644 --- a/src/Catalog/Catalog.Api/Commands/ProductCommands.cs +++ b/src/Catalog/Catalog.Api/Commands/ProductCommands.cs @@ -7,6 +7,7 @@ public record DraftWithProvidedId( string Sku, string Name, string Description, + string Brand, string CreatedBy ); @@ -14,6 +15,7 @@ public record Draft( string Sku, string Name, string Description, + string Brand, string CreatedBy ); @@ -44,4 +46,10 @@ public record AdjustDescription( string Description, string AdjustedBy ); + + public record AdjustBrand( + string ProductId, + string Brand, + string AdjustedBy + ); } diff --git a/src/Catalog/Catalog.Api/HttpApi/CommandApi.cs b/src/Catalog/Catalog.Api/HttpApi/CommandApi.cs index 350f06c..a9e7447 100644 --- a/src/Catalog/Catalog.Api/HttpApi/CommandApi.cs +++ b/src/Catalog/Catalog.Api/HttpApi/CommandApi.cs @@ -43,4 +43,9 @@ public Task> AdjustName([FromBody] AdjustName cmd, Cancella [Route("adjust-description")] public Task> AdjustDescription([FromBody] AdjustDescription cmd, CancellationToken ct) => Handle(cmd, ct); + + [HttpPost] + [Route("adjust-brand")] + public Task> AdjustBrand([FromBody] AdjustBrand cmd, CancellationToken ct) + => Handle(cmd, ct); } diff --git a/src/Catalog/Catalog.Api/Queries/ProductDocument.cs b/src/Catalog/Catalog.Api/Queries/ProductDocument.cs index d4f35a3..fbba7b7 100644 --- a/src/Catalog/Catalog.Api/Queries/ProductDocument.cs +++ b/src/Catalog/Catalog.Api/Queries/ProductDocument.cs @@ -14,4 +14,5 @@ public ProductDocument(string Id) : base(Id) public string Name { get; set; } = null!; public string Sku { get; set; } = null!; public string Description { get; set; } = null!; + public string Brand { get; set; } = null!; } diff --git a/src/Catalog/Catalog.Api/Queries/ProductStateProjection.cs b/src/Catalog/Catalog.Api/Queries/ProductStateProjection.cs index e8f63d0..870d050 100644 --- a/src/Catalog/Catalog.Api/Queries/ProductStateProjection.cs +++ b/src/Catalog/Catalog.Api/Queries/ProductStateProjection.cs @@ -1,5 +1,4 @@ using Catalog.Products; -using Eventuous; using Eventuous.Projections.MongoDB; using Eventuous.Subscriptions.Context; using MongoDB.Driver; @@ -32,7 +31,9 @@ private static UpdateDefinition Handle( .Set(x => x.CreatedBy, evt.CreatedBy) .Set(x => x.Status, nameof(ProductStatus.Drafted)) .Set(x => x.Name, evt.Name) - .Set(x => x.Sku, evt.Sku); + .Set(x => x.Sku, evt.Sku) + .Set(x => x.Description, evt.Description) + .Set(x => x.Brand, evt.Brand); } private static UpdateDefinition Handle( @@ -79,4 +80,13 @@ private static UpdateDefinition Handle( return update.Set(x => x.Description, evt.Description); } + + private static UpdateDefinition Handle( + IMessageConsumeContext ctx, + UpdateDefinition update) + { + var evt = ctx.Message; + + return update.Set(x => x.Brand, evt.Brand); + } } diff --git a/src/Catalog/Catalog/Brand.cs b/src/Catalog/Catalog/Brand.cs new file mode 100644 index 0000000..9018848 --- /dev/null +++ b/src/Catalog/Catalog/Brand.cs @@ -0,0 +1,30 @@ +using Eventuous; + +namespace Catalog; + +public class Brand +{ + public string Value { get; internal init; } = string.Empty; + + internal Brand() { } + + public Brand(string value) + { + if (string.IsNullOrWhiteSpace(value)) + throw new DomainException("Brand value cannot be empty"); + + if (value.Length <= 2) + throw new DomainException("A brand's name must exceed 2 characters"); + + if (value.Length > 64) + throw new DomainException("A brand's name cannot exceed 64 characters"); + + Value = value; + } + + public bool HasSameValue(string another) + => string.Compare(Value, another, StringComparison.CurrentCulture) != 0; + + public static implicit operator string(Brand brand) + => brand.Value; +} diff --git a/src/Catalog/Catalog/Catalog.csproj b/src/Catalog/Catalog/Catalog.csproj index 121469c..a29468a 100644 --- a/src/Catalog/Catalog/Catalog.csproj +++ b/src/Catalog/Catalog/Catalog.csproj @@ -1,21 +1,8 @@ - - - - - - - - - - <_Parameter1>$(AssemblyName).Tests - - - <_Parameter1>$(AssemblyName).WebApi.Tests - + diff --git a/src/Catalog/Catalog/Name.cs b/src/Catalog/Catalog/Name.cs index 940aac7..e4fa9d2 100644 --- a/src/Catalog/Catalog/Name.cs +++ b/src/Catalog/Catalog/Name.cs @@ -13,11 +13,13 @@ public Name(string value) if (string.IsNullOrWhiteSpace(value)) throw new DomainException("Name value cannot be empty"); - if (value.Length <= 4) - throw new DomainException("A product's name must exceed 4 characters"); + if (value.Length <= 3) + throw new DomainException("A product's name must exceed 3 characters"); - if (value.Length > 200) - throw new DomainException("A product's name cannot exceed 200 characters"); + if (value.Length > 100) + throw new DomainException("A product's name cannot exceed 100 characters"); + + Value = value; } public bool HasSameValue(string another) diff --git a/src/Catalog/Catalog/Products/Product.cs b/src/Catalog/Catalog/Products/Product.cs index b5f6966..b24a676 100644 --- a/src/Catalog/Catalog/Products/Product.cs +++ b/src/Catalog/Catalog/Products/Product.cs @@ -11,6 +11,7 @@ public async Task Draft( string sku, string name, string description, + string brand, DateTimeOffset createdAt, string createdBy, IsSkuAvailable isSkuAvailable, @@ -26,6 +27,7 @@ public async Task Draft( sku, name, description, + brand, createdAt, createdBy ) @@ -101,6 +103,20 @@ public void AdjustName(string name, DateTimeOffset adjustedAt, string adjustedBy ); } + public void AdjustBrand(string name, DateTimeOffset adjustedAt, string adjustedBy) + { + EnsureExists(); + + Apply( + new V1.ProductBrandAdjusted( + State.Id.Value, + name, + adjustedAt, + adjustedBy + ) + ); + } + private static async Task ValidateSkuAvailability(Sku sku, IsSkuAvailable isSkuAvailable) { var skuAvailable = await isSkuAvailable(sku); diff --git a/src/Catalog/Catalog/Products/ProductEvents.cs b/src/Catalog/Catalog/Products/ProductEvents.cs index c4b00bb..8ba49ce 100644 --- a/src/Catalog/Catalog/Products/ProductEvents.cs +++ b/src/Catalog/Catalog/Products/ProductEvents.cs @@ -12,6 +12,7 @@ public record ProductDrafted( string Sku, string Name, string Description, + string Brand, DateTimeOffset CreatedAt, string CreatedBy ); @@ -53,5 +54,13 @@ public record ProductDescriptionAdjusted( DateTimeOffset AdjustedAt, string AdjustedBy ); + + [EventType("V1.ProductBrandAdjusted")] + public record ProductBrandAdjusted( + string ProductId, + string Brand, + DateTimeOffset AdjustedAt, + string AdjustedBy + ); } } diff --git a/src/Catalog/Catalog/Products/ProductState.cs b/src/Catalog/Catalog/Products/ProductState.cs index 6a6b6c0..343de79 100644 --- a/src/Catalog/Catalog/Products/ProductState.cs +++ b/src/Catalog/Catalog/Products/ProductState.cs @@ -1,3 +1,4 @@ +using Ecommerce.Eventuous.Exceptions; using Eventuous; using static Catalog.Products.ProductEvents; @@ -36,50 +37,85 @@ private static ProductState Handle( Sku = new Sku(@event.Sku) }; - private static ProductState Handle(ProductState state, V1.ProductActivated @event) + private static ProductState Handle(ProductState state, V1.ProductActivated @event) => state.Status switch { - if (state.Status != ProductStatus.Drafted) - throw new DomainException($"Product must be be {nameof(ProductStatus.Drafted)} status to be activated"); - - return state with { Status = ProductStatus.Activated }; - } + ProductStatus.Archived => throw InvalidStateChangeException.For(state.Id, ProductStatus.Archived), + ProductStatus.Cancelled => throw InvalidStateChangeException.For(state.Id, ProductStatus.Cancelled), + _ => state with { Status = ProductStatus.Activated } + }; - private static ProductState Handle(ProductState state, V1.ProductArchived @event) + private static ProductState Handle(ProductState state, V1.ProductArchived @event) => state.Status switch { - if (state.Status != ProductStatus.Activated) - throw new DomainException($"Product can only be set to {nameof(ProductStatus.Archived)} while in {nameof(ProductStatus.Activated)}"); - - return state with { Status = ProductStatus.Archived }; - } + ProductStatus.Archived => throw InvalidStateChangeException.For(state.Id, ProductStatus.Archived), + ProductStatus.Cancelled => throw InvalidStateChangeException.For(state.Id, ProductStatus.Cancelled), + _ => state with { Status = ProductStatus.Archived } + }; private static ProductState Handle(ProductState state, V1.ProductDraftCancelled @event) { - if (state.Status != ProductStatus.Drafted) - throw new DomainException($"Product can only be set to {nameof(ProductStatus.Cancelled)} from {nameof(ProductStatus.Drafted)}"); + ////////// Thoughts regarding modeling state ////////// + // Does the business really need Archived and Cancelled? + // Can we model things to be more reflective of how the + // business actually handles, models, or mentally models + // these operations? + // What if we split this single model (AKA entity, aggregate, + // or stream) into two? + // Where there's a process for submitting the initial draft + // and any changes (rough drafts), and then one that has been + // approved and is now "live" or "active"? + + if (state.Status is not ProductStatus.Drafted) + throw InvalidStateChangeException.For(state.Id, ProductStatus.Drafted); return state with { Status = ProductStatus.Cancelled }; } private static ProductState Handle(ProductState state, V1.ProductDescriptionAdjusted @event) { - if (state.Status is not ProductStatus.Drafted or ProductStatus.Activated) - throw new DomainException($"Product must be set to {nameof(ProductStatus.Drafted)} or {nameof(ProductStatus.Activated)} to adjust {nameof(Description)}"); + // Validation of the value is performed early in the process before overall state is checked. + var adjustedDescription = new Description(@event.Description); - return state with + // a switch expression + return state.Status switch { - Description = new Description(@event.Description) + ProductStatus.Unset => throw InvalidStateChangeException.For(state.Id, ProductStatus.Unset), + ProductStatus.Archived => throw InvalidStateChangeException.For(state.Id, ProductStatus.Archived), + ProductStatus.Cancelled => throw InvalidStateChangeException.For(state.Id, ProductStatus.Cancelled), + _ => state with { Description = adjustedDescription } }; } private static ProductState Handle(ProductState state, V1.ProductNameAdjusted @event) { - if (state.Status is not ProductStatus.Drafted or ProductStatus.Activated) - throw new DomainException($"Product must be set to {nameof(ProductStatus.Drafted)} or {nameof(ProductStatus.Activated)} to adjust {nameof(Name)}"); - + // Validation of the value is performed early in the process before overall state is checked. var adjustedName = new Name(@event.Name); + // a switch statement + switch (state.Status) + { + case ProductStatus.Unset: + throw InvalidStateChangeException.For(state.Id, ProductStatus.Unset); + case ProductStatus.Archived: + throw InvalidStateChangeException.For(state.Id, ProductStatus.Archived); + case ProductStatus.Cancelled: + throw InvalidStateChangeException.For(state.Id, ProductStatus.Cancelled); + case ProductStatus.Drafted: + case ProductStatus.Activated: + case ProductStatus.Closed: + default: + break; + } + + ////////// Thoughts regarding event store immutability ////////// + // Is this exception okay? It should be. However, when aggregating the + // state (events) in the future, and if the event stream was illegally + // modified (as it should be immutable), then there could be issues. + // This is why it's important an event store's log truly be immutable, + // as business logic / use cases are built around expecting it to be so. + // An event store is the source of truth. An audit log. A ledger of transactions. + if (state.Name.HasSameValue(adjustedName)) - throw new DomainException("Product name is the same"); + throw InvalidStateChangeException.For(state.Id, "Incoming name value is the same as current name"); return state with { Name = adjustedName }; } diff --git a/src/Core/Ecommerce.Eventuous/Ecommerce.Eventuous.csproj b/src/Core/Ecommerce.Eventuous/Ecommerce.Eventuous.csproj new file mode 100644 index 0000000..6a99c39 --- /dev/null +++ b/src/Core/Ecommerce.Eventuous/Ecommerce.Eventuous.csproj @@ -0,0 +1,12 @@ + + + + + + + + + + + + diff --git a/src/Core/Ecommerce.Eventuous/Exceptions/InvalidStateChangeException.cs b/src/Core/Ecommerce.Eventuous/Exceptions/InvalidStateChangeException.cs new file mode 100644 index 0000000..44a8f29 --- /dev/null +++ b/src/Core/Ecommerce.Eventuous/Exceptions/InvalidStateChangeException.cs @@ -0,0 +1,57 @@ +using Eventuous; + +namespace Ecommerce.Eventuous.Exceptions; + +/// +/// TODO - expand to be easier to use, less syntax if possible, more railguard-like. +/// +public class InvalidStateChangeException(string message) : DomainException(message) +{ + private InvalidStateChangeException(string typeName, string id) + : this($"{typeName} with id '{id}' could not perform an operation.") + { + } + + private InvalidStateChangeException(string typeName, string id, string eventName) + : this($"{typeName} with id '{id}' could not successfully apply {eventName} event.") + { + } + + private InvalidStateChangeException(string typeName, Id id, string eventName) + : this($"{typeName} with id '{id}' could not successfully apply {eventName} event.") + { + } + + private InvalidStateChangeException(string typeName, Id id, Enum invalidStatus) + : this($"{typeName} with id '{id}' could not successfully apply an event while state is in {invalidStatus} status.") + { + } + + private InvalidStateChangeException(string typeName, Id id, Enum invalidStatus, string eventName) + : this($"{typeName} with id '{id}' could not successfully apply {eventName} event while state is in {invalidStatus} status.") + { + } + + private InvalidStateChangeException(string typeName, Id id, string eventName, string messageDetail = null!) + : this($"{typeName} with id '{id}' could not successfully apply {eventName} event | messageDetail: {messageDetail}") + { + } + + public static InvalidStateChangeException For(Id id) => + For(id.ToString()); + + public static InvalidStateChangeException For(string id) => + new(typeof(T).Name, id); + + public static InvalidStateChangeException For(Id id) => + new(typeof(T).Name, id, typeof(TEvent).Name); + + public static InvalidStateChangeException For(Id id, Enum invalidStatus) => + new(typeof(T).Name, id, invalidStatus); + + public static InvalidStateChangeException For(Id id, Enum invalidStatus) => + new(typeof(T).Name, id, invalidStatus, typeof(TEvent).Name); + + public static InvalidStateChangeException For(Id id, string messageDetail) => + new(typeof(T).Name, id, typeof(TEvent).Name, messageDetail); +} diff --git a/src/Inventory/Inventory.Api/Inventory.Api.csproj b/src/Inventory/Inventory.Api/Inventory.Api.csproj index 7ae150a..53322a0 100644 --- a/src/Inventory/Inventory.Api/Inventory.Api.csproj +++ b/src/Inventory/Inventory.Api/Inventory.Api.csproj @@ -1,9 +1,6 @@ - net8.0 - enable - enable Linux @@ -26,7 +23,7 @@ - + .dockerignore diff --git a/src/Inventory/Inventory/Inventory.csproj b/src/Inventory/Inventory/Inventory.csproj index 64b8cc2..a29468a 100644 --- a/src/Inventory/Inventory/Inventory.csproj +++ b/src/Inventory/Inventory/Inventory.csproj @@ -1,12 +1,8 @@ - - - - - +