Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Catalog - Improving Clarity of Business Logic #34

Merged
merged 6 commits into from
May 23, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions EventSourcingEcommerce.sln
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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}
Expand All @@ -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
2 changes: 2 additions & 0 deletions src/Catalog/Catalog.Api/Commands/ProductCommandService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ public ProductCommandService(
cmd.Sku,
cmd.Name,
cmd.Description,
cmd.Brand,
DateTimeOffset.Now,
cmd.CreatedBy,
isSkuAvailable,
Expand All @@ -35,6 +36,7 @@ public ProductCommandService(
cmd.Sku,
cmd.Name,
cmd.Description,
cmd.Brand,
DateTimeOffset.Now,
cmd.CreatedBy,
isSkuAvailable,
Expand Down
8 changes: 8 additions & 0 deletions src/Catalog/Catalog.Api/Commands/ProductCommands.cs
Original file line number Diff line number Diff line change
Expand Up @@ -7,13 +7,15 @@ public record DraftWithProvidedId(
string Sku,
string Name,
string Description,
string Brand,
string CreatedBy
);

public record Draft(
string Sku,
string Name,
string Description,
string Brand,
string CreatedBy
);

Expand Down Expand Up @@ -44,4 +46,10 @@ public record AdjustDescription(
string Description,
string AdjustedBy
);

public record AdjustBrand(
string ProductId,
string Brand,
string AdjustedBy
);
}
5 changes: 5 additions & 0 deletions src/Catalog/Catalog.Api/HttpApi/CommandApi.cs
Original file line number Diff line number Diff line change
Expand Up @@ -43,4 +43,9 @@ public Task<ActionResult<Result>> AdjustName([FromBody] AdjustName cmd, Cancella
[Route("adjust-description")]
public Task<ActionResult<Result>> AdjustDescription([FromBody] AdjustDescription cmd, CancellationToken ct)
=> Handle(cmd, ct);

[HttpPost]
[Route("adjust-brand")]
public Task<ActionResult<Result>> AdjustBrand([FromBody] AdjustBrand cmd, CancellationToken ct)
=> Handle(cmd, ct);
}
1 change: 1 addition & 0 deletions src/Catalog/Catalog.Api/Queries/ProductDocument.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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!;
}
14 changes: 12 additions & 2 deletions src/Catalog/Catalog.Api/Queries/ProductStateProjection.cs
Original file line number Diff line number Diff line change
@@ -1,5 +1,4 @@
using Catalog.Products;
using Eventuous;
using Eventuous.Projections.MongoDB;
using Eventuous.Subscriptions.Context;
using MongoDB.Driver;
Expand Down Expand Up @@ -32,7 +31,9 @@ private static UpdateDefinition<ProductDocument> 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<ProductDocument> Handle(
Expand Down Expand Up @@ -79,4 +80,13 @@ private static UpdateDefinition<ProductDocument> Handle(

return update.Set(x => x.Description, evt.Description);
}

private static UpdateDefinition<ProductDocument> Handle(
IMessageConsumeContext<V1.ProductBrandAdjusted> ctx,
UpdateDefinition<ProductDocument> update)
{
var evt = ctx.Message;

return update.Set(x => x.Brand, evt.Brand);
}
}
30 changes: 30 additions & 0 deletions src/Catalog/Catalog/Brand.cs
Original file line number Diff line number Diff line change
@@ -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;
}
15 changes: 1 addition & 14 deletions src/Catalog/Catalog/Catalog.csproj
Original file line number Diff line number Diff line change
@@ -1,21 +1,8 @@
<Project Sdk="Microsoft.NET.Sdk">

<ItemGroup>
<PackageReference Include="Eventuous.Domain" Version="$(EventuousVersion)" />
<PackageReference Include="Eventuous.Shared" Version="$(EventuousVersion)" />
</ItemGroup>

<ItemGroup>
<ProjectReference Include="..\..\Core\Ecommerce.Core\Ecommerce.Core.csproj" />
</ItemGroup>

<ItemGroup>
<AssemblyAttribute Include="System.Runtime.CompilerServices.InternalsVisibleTo">
<_Parameter1>$(AssemblyName).Tests</_Parameter1>
</AssemblyAttribute>
<AssemblyAttribute Include="System.Runtime.CompilerServices.InternalsVisibleTo">
<_Parameter1>$(AssemblyName).WebApi.Tests</_Parameter1>
</AssemblyAttribute>
<ProjectReference Include="..\..\Core\Ecommerce.Eventuous\Ecommerce.Eventuous.csproj" />
</ItemGroup>

</Project>
10 changes: 6 additions & 4 deletions src/Catalog/Catalog/Name.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
16 changes: 16 additions & 0 deletions src/Catalog/Catalog/Products/Product.cs
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ public async Task Draft(
string sku,
string name,
string description,
string brand,
DateTimeOffset createdAt,
string createdBy,
IsSkuAvailable isSkuAvailable,
Expand All @@ -26,6 +27,7 @@ public async Task Draft(
sku,
name,
description,
brand,
createdAt,
createdBy
)
Expand Down Expand Up @@ -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);
Expand Down
9 changes: 9 additions & 0 deletions src/Catalog/Catalog/Products/ProductEvents.cs
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ public record ProductDrafted(
string Sku,
string Name,
string Description,
string Brand,
DateTimeOffset CreatedAt,
string CreatedBy
);
Expand Down Expand Up @@ -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
);
}
}
80 changes: 58 additions & 22 deletions src/Catalog/Catalog/Products/ProductState.cs
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
using Ecommerce.Eventuous.Exceptions;
using Eventuous;

using static Catalog.Products.ProductEvents;
Expand Down Expand Up @@ -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<Product, V1.ProductDescriptionAdjusted>(state.Id, ProductStatus.Archived),
ProductStatus.Cancelled => throw InvalidStateChangeException.For<Product, V1.ProductDescriptionAdjusted>(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<Product, V1.ProductArchived>(state.Id, ProductStatus.Archived),
ProductStatus.Cancelled => throw InvalidStateChangeException.For<Product, V1.ProductArchived>(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<Product, V1.ProductDraftCancelled>(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<Product, V1.ProductDescriptionAdjusted>(state.Id, ProductStatus.Unset),
ProductStatus.Archived => throw InvalidStateChangeException.For<Product, V1.ProductDescriptionAdjusted>(state.Id, ProductStatus.Archived),
ProductStatus.Cancelled => throw InvalidStateChangeException.For<Product, V1.ProductDescriptionAdjusted>(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<Product, V1.ProductNameAdjusted>(state.Id, ProductStatus.Unset);
case ProductStatus.Archived:
throw InvalidStateChangeException.For<Product, V1.ProductNameAdjusted>(state.Id, ProductStatus.Archived);
case ProductStatus.Cancelled:
throw InvalidStateChangeException.For<Product, V1.ProductNameAdjusted>(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<Product, V1.ProductNameAdjusted>(state.Id, "Incoming name value is the same as current name");

return state with { Name = adjustedName };
}
Expand Down
12 changes: 12 additions & 0 deletions src/Core/Ecommerce.Eventuous/Ecommerce.Eventuous.csproj
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
<Project Sdk="Microsoft.NET.Sdk">

<ItemGroup>
<PackageReference Include="Eventuous.Domain" Version="$(EventuousVersion)" />
<PackageReference Include="Eventuous.Shared" Version="$(EventuousVersion)" />
</ItemGroup>

<ItemGroup>
<ProjectReference Include="..\..\Core\Ecommerce.Core\Ecommerce.Core.csproj" />
</ItemGroup>

</Project>
Loading
Loading