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->withTax(0.1); // 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 = new EmailAddress("invalid-email"); // An exception is raised
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 |
| Thread safety | Safe even with concurrent access from multiple threads | The state is not corrupted under concurrent processing |
| Safe value sharing | You can use the same instance in multiple places | No copying needed, memory-efficient |
Mutable vs immutable
// A mutable object (bad example)
class MutableMoney
{
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.
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;
}
}
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 (empty($prefecture) || empty($city) || empty($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 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 resource.
- ValueObject - Martin Fowler: an explanation by Martin Fowler, who proposed the concept of the value object
In the next chapter, we look at entities in detail.