Chapter 2: More Than Just Primitives - Value Objects
Of course. Now that we understand the Rich Domain Model, let's delve into one of the most important tactical patterns in DDD: Value Objects.
The Scenario 📝
- System: The e-commerce application from the previous chapter.
- Problem: Although our
Orderclass now has behavior (Cancel()), it still uses primitive data types to represent more complex business concepts.
Problematic Code (Using Primitives):
csharp
public class Order
{
public decimal Price { get; private set; } // Just a number, what about the currency?
public int Quantity { get; private set; }
// An address is just a bunch of strings
public string Street { get; private set; }
public string City { get; private set; }
public string PostalCode { get; private set; }
}The Problem: "Primitive Obsession" 🧐
This is a "code smell" known as "Primitive Obsession": an obsession with using primitive data types (string, int, decimal, etc.).
- Loss of Context: A
decimalforPricedoesn't tell you if it's inUSDorVND. AstringforPostalCodedoesn't contain any formatting rules. - Scattered Logic: The logic for validating an address or performing calculations on money is scattered across multiple places (
Services,Controllers, etc.) instead of being centralized. - Unclear Intent: A method like
UpdateAddress(string street, string city, string postalCode)is prone to having its parameters passed in the wrong order.
The Solution: Use Value Objects ✅
The Logic: We will create small, immutable objects that have no identity (no
Id) to represent these concepts. Two Value Objects are considered equal if all their properties are equal.C#
recordis a perfect tool for creating Value Objects.Optimized Code:
Create a
MoneyValue Object:csharp// Use a record for built-in comparison and immutability public record Money(decimal Amount, string Currency) { // Validation logic is encapsulated right in the constructor public Money { if (Amount < 0) throw new ArgumentException("Amount cannot be negative."); if (string.IsNullOrWhiteSpace(Currency)) throw new ArgumentException("Currency is required."); } // Behaviors related to money can be added here public Money Add(Money other) { if (this.Currency != other.Currency) throw new InvalidOperationException("Cannot add different currencies."); return new Money(this.Amount + other.Amount, this.Currency); } }Create an
AddressValue Object:csharppublic record Address(string Street, string City, string PostalCode) { public Address { if (string.IsNullOrWhiteSpace(Street)) throw new ArgumentException("Street is required."); // ... other validation logic ... } }Refactor the
Orderclass:csharppublic class Order { public Money TotalPrice { get; private set; } public Address ShippingAddress { get; private set; } // ... }
Analysis of the Result ✨
- Clarity and Intent: The code becomes much more expressive. A method signature
UpdateShippingAddress(Address newAddress)is far clearer and safer than one with three string parameters. - Centralized Logic: All validation and behavior related to a concept (like
MoneyorAddress) are now in one place. If you need to change how a postal code is validated, you only need to change it in theAddressrecord. - Guaranteed Validity: Once a
Value Objectis created, it is guaranteed to be valid. You no longer need to re-validate it in different layers of the application. - Immutability: Because they are immutable,
Value Objectsare safe to pass around the system without fear of them being changed unexpectedly.