Skip to content

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 Order class 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.).

  1. Loss of Context: A decimal for Price doesn't tell you if it's in USD or VND. A string for PostalCode doesn't contain any formatting rules.
  2. 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.
  3. 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# record is a perfect tool for creating Value Objects.

  • Optimized Code:

    1. Create a Money Value 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);
          }
      }
    2. Create an Address Value Object:

      csharp
      public record Address(string Street, string City, string PostalCode)
      {
          public Address
          {
              if (string.IsNullOrWhiteSpace(Street)) throw new ArgumentException("Street is required.");
              // ... other validation logic ...
          }
      }
    3. Refactor the Order class:

      csharp
      public class Order
      {
          public Money TotalPrice { get; private set; }
          public Address ShippingAddress { get; private set; }
          // ...
      }

Analysis of the Result ✨

  1. 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.
  2. Centralized Logic: All validation and behavior related to a concept (like Money or Address) are now in one place. If you need to change how a postal code is validated, you only need to change it in the Address record.
  3. Guaranteed Validity: Once a Value Object is created, it is guaranteed to be valid. You no longer need to re-validate it in different layers of the application.
  4. Immutability: Because they are immutable, Value Objects are safe to pass around the system without fear of them being changed unexpectedly.