Skip to content

E-Commerce Domain

decimal price = 10000; — is this amount in KRW or USD? What happens if product code "invalid" is included in an order? When business concepts are expressed as primitive types in e-commerce systems, currency confusion, format errors, and invalid state transitions go undetected until runtime.

In this chapter, we apply the patterns and techniques learned in Part 1~4 to a real e-commerce domain, implementing 5 value objects that prevent these problems through the type system.

  • Money: Composite value object that manages amount and currency together
  • ProductCode: Single value object that validates product code format
  • Quantity: Comparable value object expressing quantities with sorting and arithmetic capabilities
  • OrderStatus: Type-safe enumeration expressing order status and transition rules
  • ShippingAddress: Composite value object expressing shipping address
  • You can implement Domain Operations such as Add and Subtract in value objects with multiple properties like Money.
  • You can validate business formats using regular expressions like ProductCode.
  • You can implement a state machine using SmartEnum in OrderStatus.
  • You can sequentially validate multiple fields like ShippingAddress.
  • Money’s per-currency operation restrictions and amount calculation
  • ProductCode’s category-number parsing
  • Quantity’s arithmetic operator overloading
  • OrderStatus’s valid state transition verification
  • ShippingAddress’s multi-field validation

E-commerce systems deal with various business concepts such as amounts, quantities, and product codes. Expressing these concepts as primitive types causes several problems.

Expressing it as decimal price = 10000; makes it impossible to know whether this is KRW or USD. The Money value object manages amount and currency together to prevent incorrect operations between different currencies. If product codes are string, values like "invalid" can be assigned anywhere, but the ProductCode value object validates format at creation time ensuring only valid formats exist. Managing order status with only string or enum allows abnormal transitions like changing from “Delivered” to “Pending”, but OrderStatus implements a state machine that only allows valid transitions.

Money is a composite value object that manages Amount and Currency together. Operations are only possible between the same currency.

public sealed class Money : ValueObject, IComparable<Money>
{
public sealed record CurrencyEmpty : DomainErrorType.Custom;
public sealed record CurrencyNotThreeCharacters : DomainErrorType.Custom;
public decimal Amount { get; }
public string Currency { get; }
private Money(decimal amount, string currency)
{
Amount = amount;
Currency = currency;
}
public static Fin<Money> Create(decimal amount, string? currency) =>
CreateFromValidation(
Validate(amount, currency ?? ""),
validValues => new Money(validValues.Amount, validValues.Currency.ToUpperInvariant()));
public static Validation<Error, (decimal Amount, string Currency)> Validate(decimal amount, string currency) =>
(ValidateAmountNotNegative(amount), ValidateCurrencyNotEmpty(currency), ValidateCurrencyLength(currency))
.Apply((validAmount, validCurrency, _) => (validAmount, validCurrency));
private static Validation<Error, decimal> ValidateAmountNotNegative(decimal amount) =>
amount >= 0
? amount
: DomainError.For<Money, decimal>(new DomainErrorType.Negative(), amount,
$"Amount cannot be negative. Current value: '{amount}'");
public Money Add(Money other)
{
if (Currency != other.Currency)
throw new InvalidOperationException("Cannot add amounts of different currencies.");
return new Money(Amount + other.Amount, Currency);
}
public Money Subtract(Money other)
{
if (Currency != other.Currency)
throw new InvalidOperationException("Cannot subtract amounts of different currencies.");
return new Money(Amount - other.Amount, Currency);
}
public Money Multiply(decimal factor) => new(Amount * factor, Currency);
protected override IEnumerable<object> GetEqualityComponents()
{
yield return Amount;
yield return Currency;
}
}

Operations like Add, Subtract verify currency match first. An attempt to add USD and KRW raises an exception at runtime. Generic currency types could be used for compile-time prevention, but runtime validation is more flexible in practice.

ProductCode validates product codes in "EL-001234" format. It also provides the ability to parse category (2-letter alpha) and number (6-digit numeric).

public sealed class ProductCode : SimpleValueObject<string>
{
private ProductCode(string value) : base(value) { }
public string Code => Value; // Public accessor for protected Value
public string Category => Value[..2]; // "EL"
public string Number => Value[3..]; // "001234"
public static Fin<ProductCode> Create(string? value) =>
CreateFromValidation(
Validate(value ?? ""),
validValue => new ProductCode(validValue));
public static Validation<Error, string> Validate(string value) =>
ValidateNotEmpty(value)
.Bind(_ => ValidateFormat(value));
private static Validation<Error, string> ValidateNotEmpty(string value) =>
!string.IsNullOrWhiteSpace(value)
? value
: DomainError.For<ProductCode>(new DomainErrorType.Empty(), value,
$"Product code cannot be empty. Current value: '{value}'");
private static Validation<Error, string> ValidateFormat(string value)
{
var normalized = value.ToUpperInvariant().Trim();
return Regex.IsMatch(normalized, @"^[A-Z]{2}-\d{6}$")
? normalized
: DomainError.For<ProductCode>(new DomainErrorType.InvalidFormat(), value,
$"Product code must match 'XX-NNNNNN' pattern. Current value: '{value}'");
}
public static implicit operator string(ProductCode code) => code.Value;
}

Since only valid ProductCodes can exist, the Category and Number properties can always be safely accessed. This is a pattern where format validation and parsing are combined in a single value object.

Quantity is a comparable value object capable of arithmetic operations. It validates negative values and maximum limits.

public sealed class Quantity : ComparableSimpleValueObject<int>
{
private Quantity(int value) : base(value) { }
public int Amount => Value; // Public accessor for protected Value
public static Quantity Zero => new(0);
public static Quantity One => new(1);
public static Fin<Quantity> Create(int value) =>
CreateFromValidation(
Validate(value),
validValue => new Quantity(validValue));
public static Validation<Error, int> Validate(int value) =>
ValidateNotNegative(value)
.Bind(_ => ValidateNotExceedsLimit(value))
.Map(_ => value);
private static Validation<Error, int> ValidateNotNegative(int value) =>
value >= 0
? value
: DomainError.For<Quantity, int>(new DomainErrorType.Negative(), value,
$"Quantity cannot be negative. Current value: '{value}'");
public Quantity Add(Quantity other) => new(Value + other.Value);
public Quantity Subtract(Quantity other) => new(Math.Max(0, Value - other.Value));
public static Quantity operator +(Quantity a, Quantity b) => a.Add(b);
public static Quantity operator -(Quantity a, Quantity b) => a.Subtract(b);
public static implicit operator int(Quantity quantity) => quantity.Value;
}

Thanks to operator overloading, expressions like qty1 + qty2, qty1 > qty2 are possible, making domain logic intuitive.

OrderStatus is a type-safe enumeration using SmartEnum. It encapsulates each status properties (cancellability) and transition rules.

public sealed class OrderStatus : SmartEnum<OrderStatus, string>
{
public sealed record AlreadyCancelled : DomainErrorType.Custom;
public sealed record AlreadyDelivered : DomainErrorType.Custom;
public sealed record CannotRevertToPending : DomainErrorType.Custom;
public static readonly OrderStatus Pending = new("PENDING", "Pending", canCancel: true);
public static readonly OrderStatus Confirmed = new("CONFIRMED", "Confirmed", canCancel: true);
public static readonly OrderStatus Shipped = new("SHIPPED", "Shipping", canCancel: false);
public static readonly OrderStatus Delivered = new("DELIVERED", "Delivered", canCancel: false);
public static readonly OrderStatus Cancelled = new("CANCELLED", "Cancelled", canCancel: false);
public string DisplayName { get; }
public bool CanCancel { get; }
public Fin<OrderStatus> TransitionTo(OrderStatus next)
{
return (this, next) switch
{
(var s, _) when s == Cancelled => DomainError.For<OrderStatus>(
new AlreadyCancelled(), $"{Value}->{next.Value}",
$"Cannot change status of a cancelled order. Current: '{Value}', Target: '{next.Value}'"),
(var s, _) when s == Delivered => DomainError.For<OrderStatus>(
new AlreadyDelivered(), $"{Value}->{next.Value}",
$"Cannot change status of a delivered order. Current: '{Value}', Target: '{next.Value}'"),
(_, var n) when n == Pending => DomainError.For<OrderStatus>(
new CannotRevertToPending(), $"{Value}->{next.Value}",
$"Cannot revert to pending status. Current: '{Value}', Target: '{next.Value}'"),
_ => next
};
}
}

Business rules such as cancelled orders cannot change status and cannot revert to pending status are defined within the value object. Since state transition rules are encapsulated, attempting an invalid transition from outside returns a domain error.

ShippingAddress is a composite value object containing recipient, street, city, postal code, and country.

public sealed class ShippingAddress : ValueObject
{
public sealed record RecipientNameEmpty : DomainErrorType.Custom;
public sealed record StreetEmpty : DomainErrorType.Custom;
public sealed record CityEmpty : DomainErrorType.Custom;
public sealed record CountryEmpty : DomainErrorType.Custom;
public string RecipientName { get; }
public string Street { get; }
public string City { get; }
public string PostalCode { get; }
public string Country { get; }
private ShippingAddress(string recipientName, string street, string city, string postalCode, string country)
{
RecipientName = recipientName; Street = street; City = city;
PostalCode = postalCode; Country = country;
}
public static Fin<ShippingAddress> Create(
string? recipientName, string? street, string? city, string? postalCode, string? country) =>
CreateFromValidation(
Validate(recipientName ?? "", street ?? "", city ?? "", postalCode ?? "", country ?? ""),
v => new ShippingAddress(v.RecipientName.Trim(), v.Street.Trim(), v.City.Trim(),
v.PostalCode, v.Country.Trim().ToUpperInvariant()));
public static Validation<Error, (string RecipientName, string Street, string City, string PostalCode, string Country)>
Validate(string recipientName, string street, string city, string postalCode, string country) =>
(ValidateRecipientName(recipientName), ValidateStreet(street), ValidateCity(city), ValidateCountry(country))
.Apply((r, s, c, co) => (r, s, c, co))
.Bind(values => ValidatePostalCode(postalCode)
.Map(p => (values.r, values.s, values.c, p, values.co)));
protected override IEnumerable<object> GetEqualityComponents()
{
yield return RecipientName; yield return Street; yield return City;
yield return PostalCode; yield return Country;
}
}

Each field is validated in order, returning immediately on the first error. This is a representative example showing the sequential validation pattern for multiple fields.

=== E-Commerce Domain Value Objects ===
1. Money (Amount) - ComparableValueObject
────────────────────────────────────────
Product price: 10,000 KRW
Discount amount: 1,000 KRW
Final price: 9,000 KRW
Different currency addition attempt: Cannot add amounts of different currencies.
2. ProductCode (Product Code) - SimpleValueObject
────────────────────────────────────────
Product code: EL-001234
Category: EL
Number: 001234
Invalid format: Product code format is invalid. (e.g., EL-001234)
3. Quantity - ComparableSimpleValueObject
────────────────────────────────────────
Quantity 1: 5
Quantity 2: 3
Total: 8
Comparison: 5 > 3 = True
Sorting: [1, 3, 5]
4. OrderStatus (Order Status) - SmartEnum
────────────────────────────────────────
Current status: Pending
Cancellable: True
After transition: Confirmed
Shipping: Shipping, Cancellable: False
5. ShippingAddress (Shipping Address) - ValueObject
────────────────────────────────────────
Recipient: Hong Gildong
Address: 123 Teheran-ro, Seoul
Postal code: 06234
Country: KR
Empty address validation result: Recipient name is empty.
01-Ecommerce-Domain/
├── EcommerceDomain/
│ ├── Program.cs # Main executable (5 value object implementations)
│ └── EcommerceDomain.csproj # Project file
└── README.md # Project documentation
<ItemGroup>
<ProjectReference Include="..\..\..\..\..\Src\Functorium\Functorium.csproj" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="Ardalis.SmartEnum" />
</ItemGroup>

The following table summarizes which framework base type each value object inherits and what characteristics it has.

value objectFramework TypeCharacteristics
MoneyValueObject + IComparableComposite value, same currency operations
ProductCodeSimpleValueObject<string>Format validation, parsing
QuantityComparableSimpleValueObject<int>operator overloading
OrderStatusSmartEnumState transition rules
ShippingAddressValueObjectMulti-field validation

You can compare the properties, validation rules, and domain operations of each value object at a glance.

value objectKey PropertiesValidation RulesDomain Operations
MoneyAmount, CurrencyNo negative amount, 3-character currency codeAdd, Subtract, Multiply
ProductCodeValueXX-NNNNNN formatCategory, Number parsing
QuantityValue0-10000 range+, -, comparison
OrderStatusValue, DisplayNameValid states onlyTransitionTo
ShippingAddress5 fieldsAll fields requiredNone

The following classifies the validation patterns used in the e-commerce domain by type.

Patternvalue objectDescription
Single condition validationQuantityRange check
Regex validationProductCodeFormat pattern matching
Multi-field sequential validationShippingAddressValidate each field in order
Composite condition validationMoneyValidate amount and currency separately
State transition validationOrderStatusCurrent-target status combination verification

Q1: How to support operations between different currencies in Money?

Section titled “Q1: How to support operations between different currencies in Money?”

One approach is to inject an exchange rate conversion service and operate after conversion. Alternatively, you can create a separate MoneyConverter domain service that converts two Money objects to the same currency before operating.

public Money ConvertTo(string targetCurrency, IExchangeRateService rateService)
{
if (Currency == targetCurrency)
return this;
var rate = rateService.GetRate(Currency, targetCurrency);
return new Money(Amount * rate, targetCurrency);
}

Q2: How to manage more complex state transitions in OrderStatus?

Section titled “Q2: How to manage more complex state transitions in OrderStatus?”

You can use a state machine library (like Stateless) or create a separate OrderStatusTransition value object to explicitly manage transition rules.

public static readonly Dictionary<(OrderStatus From, OrderStatus To), bool> AllowedTransitions = new()
{
{ (Pending, Confirmed), true },
{ (Confirmed, Shipped), true },
{ (Shipped, Delivered), true },
{ (Pending, Cancelled), true },
{ (Confirmed, Cancelled), true }
};

Q3: How to allow negative results in Quantity?

Section titled “Q3: How to allow negative results in Quantity?”

The current implementation prevents negatives in Subtract with Math.Max(0, ...). To allow negatives, you can create a separate SignedQuantity type or return the result as Fin<T>.

// Method 1: Version allowing negatives
public Quantity SubtractAllowNegative(Quantity other) =>
new(Value - other.Value);
// Method 2: Return result as Fin<T>
public Fin<Quantity> SafeSubtract(Quantity other)
{
var result = Value - other.Value;
return result >= 0
? new Quantity(result)
: DomainError.For<Quantity, int>(new DomainErrorType.Negative(), result,
$"Result would be negative. Current: '{Value}', Other: '{other.Value}'");
}

We have explored the value object implementation for the e-commerce domain. In the next chapter, we implement value objects for the finance domain where accuracy and security are particularly important, including account numbers, interest rates, and exchange rates.


This project includes unit tests.

Terminal window
cd EcommerceDomain.Tests.Unit
dotnet test
EcommerceDomain.Tests.Unit/
├── MoneyTests.cs # Composite value object, currency operation tests
├── ProductCodeTests.cs # Format validation, parsing tests
├── QuantityTests.cs # Arithmetic operation, comparison tests
├── OrderStatusTests.cs # State transition rule tests
└── ShippingAddressTests.cs # Multi-field validation tests
Test ClassTest Content
MoneyTestsCreation validation, same currency operations, different currency operation prohibition
ProductCodeTestsFormat validation, category/number parsing
QuantityTestsRange validation, +/- operations, comparison operators
OrderStatusTestsState transition rules, cancellability
ShippingAddressTestsRequired field validation, equality

We have implemented the e-commerce domain value objects. In the next chapter, we cover value objects in the finance domain requiring precise calculations such as account numbers, interest rates, and exchange rates.

Chapter 2: Finance Domain