Value Objects — Immutable Domain Objects That Encapsulate Business Rules
What is a value object?
The "domain objects" you learned about in the previous chapter come in two kinds: value objects and entities. This chapter looks at value objects in detail.
A value object is a domain object with the following characteristics.
- It has no identifier
- It holds data and business rules
- Identity is judged by all attribute values
- It is immutable (it cannot be changed once created)
add(Money): can only add amounts of the same currencymultiply(int): multiplies by a quantity and returns a new Money
Why use a value object?
You might think, "Aren't primitive types (int, string, etc.) enough?" Let's look at the benefits of using a value object.
Benefit 1: You can encapsulate business rules
// With a primitive type: the rules are scattered
$price = 1000;
$tax = $price * 0.1;
$total = $price + $tax; // The tax-inclusive calculation is scattered all over the code
// With a value object: the rules are collected
$price = new Money(1000, 'JPY');
$total = $price->add($price->percentage(10)); // The tax-inclusive calculation is collected in the Money class
Benefit 2: You can prevent invalid values
// With a primitive type: an invalid value can get in
$email = "invalid-email"; // No validation
// With a value object: validation at creation time
$email = EmailAddress::fromString("invalid-email"); // An exception is raised (creation goes through the named constructor covered later)
Benefit 3: Safety through types
// With a primitive type: it is easy to get the argument order wrong
function createUser(string $name, string $email, string $phone) { /* ... */ }
createUser($email, $name, $phone); // You don't notice even if you get it wrong
// With a value object: the types protect you
function createUser(UserName $name, EmailAddress $email, PhoneNumber $phone) { /* ... */ }
createUser($email, $name, $phone); // Caught by a type error
What is immutability?
One of the important characteristics of a value object is immutability. Immutable means that you cannot change the internal state once it is created.
Why immutability matters
Immutability has the following benefits.
| Benefit | Description | Example |
|---|---|---|
| Preventing side effects | The original object does not change on a method call | With $total = $money1->add($money2), $money1 does not change |
| Improved predictability | The same input always gives the same result | $money->add($other) is safe no matter how many times you call it |
| Safe value sharing | Even if you reuse the same instance in many places, you never have to worry about some other piece of code rewriting it | You can put it in an array or a collection and pass it around |
Mutable vs immutable
// A mutable object (bad example)
class MutableMoney
{
public function __construct(private int $amount) {}
public function add(MutableMoney $other): void
{
$this->amount += $other->amount; // Changes itself
}
}
$price = new MutableMoney(1000);
$tax = new MutableMoney(100);
$price->add($tax); // $price is changed to 1100!
// Problem: the original price (1000 yen) is lost
// Problem: the value may change in an unexpected place
// An immutable object (good example)
final class Money
{
public function __construct(
private readonly int $amount,
) {}
public function add(Money $other): Money
{
return new Money($this->amount + $other->amount); // Returns a new instance
}
}
$price = new Money(1000);
$tax = new Money(100);
$total = $price->add($tax); // $price is unchanged; a new $total is created
// Benefit: the original price (1000 yen) is retained
// Benefit: changes to the value are easy to track
- Use the
readonlymodifier (PHP 8.1+) - Make properties
privateand do not create setters - Have mutating methods return a new instance
- Make the class
finalto prevent inheritance
Implementation patterns for value objects
Pattern 1: A basic value object (Money)
// final: this class cannot be inherited (it is common not to let value objects be inherited)
final class Money
{
public function __construct(
// PHP 8.1+: the readonly modifier guarantees immutability
// It is private, so it cannot be accessed directly from outside the class
private readonly int $amount,
private readonly string $currency,
) {
// Validation in the constructor: prevent invalid values at creation time
// A value object guarantees it is "always in a valid state"
if ($amount < 0) {
throw new InvalidArgumentException('Amount must be 0 or greater');
}
}
// Identity judgment: a value object compares all attributes (an entity compares by identifier)
public function equals(Money $other): bool
{
return $this->amount === $other->amount
&& $this->currency === $other->currency;
}
// Immutability: does not change itself, returns a new object
// This makes the code free of side effects and easier to predict
public function add(Money $other): Money
{
// Business rule: you cannot add amounts of different currencies
if ($this->currency !== $other->currency) {
throw new InvalidArgumentException('Currency mismatch');
}
// Does not change itself; creates and returns a new Money
return new Money($this->amount + $other->amount, $this->currency);
}
public function multiply(int $multiplier): Money
{
return new Money($this->amount * $multiplier, $this->currency);
}
// Subtraction: like addition, allow only the same currency
public function subtract(Money $other): Money
{
if ($this->currency !== $other->currency) {
throw new InvalidArgumentException('Currency mismatch');
}
return new Money($this->amount - $other->amount, $this->currency);
}
// Percentage calculation: compute an amount by a percentage (e.g. a 10% discount amount)
// multiply is only for integer multiples, so separate rate calculations into a dedicated method
// Use intdiv for integer division (truncating the remainder). If a rounding rule is needed, collect it here
public function percentage(int $percent): Money
{
return new Money(intdiv($this->amount * $percent, 100), $this->currency);
}
// Comparison methods: compare the magnitude of amounts
public function isGreaterThan(Money $other): bool
{
if ($this->currency !== $other->currency) {
throw new InvalidArgumentException('Currency mismatch');
}
return $this->amount > $other->amount;
}
public function isGreaterThanOrEqual(Money $other): bool
{
if ($this->currency !== $other->currency) {
throw new InvalidArgumentException('Currency mismatch');
}
return $this->amount >= $other->amount;
}
// Getters: methods for retrieving the values
// By making the properties private, you restrict reading and writing from outside the class
// and expose only the values you intentionally publish through getters
public function amount(): int { return $this->amount; }
public function currency(): string { return $this->currency; }
}
Pattern 2: Named constructors
When there are multiple ways to create an object, named constructors are handy. This section makes the constructor private so that objects can only be created through the static methods, but the later chapters prioritize readability and use the Pattern 1 style, new Money(...).
final class Money
{
private function __construct(
private readonly int $amount,
private readonly string $currency,
) {
if ($amount < 0) {
throw new InvalidArgumentException('Amount must be 0 or greater');
}
}
// A dedicated method to create JPY
public static function jpy(int $amount): self
{
return new self($amount, 'JPY');
}
// A dedicated method to create USD
public static function usd(int $amount): self
{
return new self($amount, 'USD');
}
// Create from a string
public static function fromString(string $value): self
{
// Parse a format like "1000 JPY"
[$amount, $currency] = explode(' ', $value);
return new self((int)$amount, $currency);
}
// ... other methods
}
// Example usage
$price = Money::jpy(1000); // The intent is clear
$usdPrice = Money::usd(10);
$parsed = Money::fromString('1000 JPY');
Pattern 3: A value object with complex validation
final class EmailAddress
{
private function __construct(
private readonly string $value,
) {
// Complex validation logic
if (!filter_var($value, FILTER_VALIDATE_EMAIL)) {
throw new InvalidArgumentException("Invalid email address: {$value}");
}
// Business rules such as domain restrictions are also possible
$domain = substr($value, strpos($value, '@') + 1);
if ($domain === 'example.com') {
throw new InvalidArgumentException('The example.com domain cannot be used');
}
}
public static function fromString(string $value): self
{
return new self(trim(strtolower($value))); // Normalize before creating
}
public function value(): string
{
return $this->value;
}
public function domain(): string
{
return substr($this->value, strpos($this->value, '@') + 1);
}
public function equals(EmailAddress $other): bool
{
return $this->value === $other->value;
}
}
UserName follows the same shape. User::create() in Chapter 8 and the repository in Chapter 20 both use this type.
final class UserName
{
private function __construct(
private readonly string $value,
) {
if ($value === '') {
throw new InvalidArgumentException('The user name cannot be empty');
}
if (mb_strlen($value) > 50) {
throw new InvalidArgumentException('The user name must be 50 characters or fewer');
}
}
public static function fromString(string $value): self
{
return new self(trim($value));
}
public function value(): string
{
return $this->value;
}
public function equals(UserName $other): bool
{
return $this->value === $other->value;
}
}
Pattern 4: A value object with a range
final class AgeRange
{
private function __construct(
private readonly int $min,
private readonly int $max,
) {
if ($min < 0 || $max < 0) {
throw new InvalidArgumentException('Age must be 0 or greater');
}
if ($min > $max) {
throw new InvalidArgumentException('The minimum must be less than or equal to the maximum');
}
}
public static function create(int $min, int $max): self
{
return new self($min, $max);
}
// Check whether an age is within the range
public function contains(int $age): bool
{
return $age >= $this->min && $age <= $this->max;
}
// Check whether the ranges overlap
public function overlaps(AgeRange $other): bool
{
return $this->min <= $other->max && $other->min <= $this->max;
}
public function min(): int { return $this->min; }
public function max(): int { return $this->max; }
}
Example usage
$money1 = new Money(1000, 'JPY');
$money2 = new Money(1000, 'JPY');
$money3 = new Money(1000, 'USD');
$money1->equals($money2); // true ← all attributes (amount, currency) are the same
$money1->equals($money3); // false ← the currency differs
// Immutability: add returns a new object
$total = $money1->add($money2); // new Money(2000, 'JPY')
// $money1 is not changed (still 1000 yen)
A practical pattern: ShippingAddress
final class ShippingAddress
{
public function __construct(
private readonly string $prefecture,
private readonly string $city,
private readonly string $street,
) {
if ($prefecture === '' || $city === '' || $street === '') {
throw new InvalidArgumentException('Each address field is required');
}
}
public function fullAddress(): string
{
return $this->prefecture . ' ' . $this->city . ' ' . $this->street;
}
public function equals(ShippingAddress $other): bool
{
return $this->prefecture === $other->prefecture
&& $this->city === $other->city
&& $this->street === $other->street;
}
// Getters
public function prefecture(): string { return $this->prefecture; }
public function city(): string { return $this->city; }
public function street(): string { return $this->street; }
}
Identifiers are value objects too
By implementing an entity's identifier (ID)—entities are explained in detail in the next chapter—as a value object, you can increase type safety.
final class OrderId
{
public function __construct(private readonly int $value)
{
if ($value <= 0) {
throw new InvalidArgumentException('OrderId must be a positive integer');
}
}
public function value(): int { return $this->value; }
public function equals(OrderId $other): bool
{
return $this->value === $other->value;
}
}
// The user identifier is defined the same way (used in the example below and from Chapter 8 on)
final class UserId
{
public function __construct(private readonly int $value)
{
if ($value <= 0) {
throw new InvalidArgumentException('UserId must be a positive integer');
}
}
public function value(): int { return $this->value; }
public function equals(UserId $other): bool
{
return $this->value === $other->value;
}
}
The benefits of making an identifier a value object
// With a primitive type
function findOrder(int $orderId): Order { /* ... */ }
function findUser(int $userId): User { /* ... */ }
findOrder($userId); // You don't notice the mistake!
// With a value object
function findOrder(OrderId $orderId): Order { /* ... */ }
function findUser(UserId $userId): User { /* ... */ }
findOrder($userId); // A type error!
Design guidelines for value objects
| Guideline | Description |
|---|---|
| Make it immutable | Use readonly and do not create setters |
| Validate at creation time | Reject invalid values in the constructor |
| Implement equals() | A method for judging identity |
| Give it business logic | Put related calculations and conversions in methods |
A value object does not necessarily "belong to" an entity
A value object does not have to be part of an entity.
A value object such as Money does not belong to a particular entity and is used in many places.
Further reading
If you want to learn more about value objects, see the following resources.
- ValueObject - Martin Fowler: an explanation by Martin Fowler, who documented the value object pattern
- Eric Evans, "Domain-Driven Design: Tackling Complexity in the Heart of Software" (2003): the original text of DDD, which covers value objects as well
- Vaughn Vernon, "Implementing Domain-Driven Design" - a practical DDD book rich in implementation examples for value objects
In the next chapter, we look at entities in detail.