TL;DR
π‘ Value Objects make impossible states impossible
β οΈ "Stringly typed" code causes 60% of production bugs
β
One VO prevents hundreds of validation checks
π₯ Eric Evans' rule: "Make meaningful, not just wrap primitives"
βββββββββββββββββββββββββββββ
π± The Problem: Primitive Obsession Hell
Last year I reviewed code that crashed production. The bug? Someone passed "usd" instead of "USD" to a payment gateway.
The killer line:
$order->setTotal("100"); // String? Float? With currency?
$order->setCurrency("usd"); // Lowercase? Uppercase? Valid?
This happens everywhere:
// What could go wrong? π₯
function createUser(string $email, string $phone, string $price) {
// Is email validated? No idea.
// Is phone formatted? Who knows.
// Is price "100" or "100.00" or "$100"? Good luck.
}
β οΈ Common Disasters:
-
calculateDiscount(50, 100)β which is price, which is percentage? -
"2024-03-15"vs"15-03-2024"β format confusion -
nullfor email β is it allowed or a bug? - Validating email in 17 different places
π¬ "Primitive Obsession is the root of all evil in enterprise code."
β Martin Fowler
βββββββββββββββββββββββββββββ
π― The Solution: Value Objects to the Rescue
What's a Value Object?
A Value Object is an immutable object compared by value, not identity.
Key traits:
- β Immutable (can't change after creation)
- β Self-validating (invalid VOs can't exist)
- β Compared by value (two identical emails are equal)
- β
No identity (no
getId()method)
π₯ Real Example: Email
β Before (Primitive Hell):
class User
{
private string $email; // Any string works π±
public function setEmail(string $email): void
{
// Validation? Maybe. Maybe not.
if (!filter_var($email, FILTER_VALIDATE_EMAIL)) {
throw new \Exception('Invalid email');
}
$this->email = strtolower($email);
}
}
// Now validate EVERYWHERE you use email
if (!filter_var($email, FILTER_VALIDATE_EMAIL)) { /* ... */ }
β
After (Value Object):
final class Email
{
private string $value;
private function __construct(string $value)
{
$normalized = strtolower(trim($value));
if (!filter_var($normalized, FILTER_VALIDATE_EMAIL)) {
throw new InvalidEmailException($value);
}
$this->value = $normalized;
}
public static function fromString(string $value): self
{
return new self($value);
}
public function toString(): string
{
return $this->value;
}
public function equals(Email $other): bool
{
return $this->value === $other->value;
}
}
Usage:
class User
{
private Email $email; // β Invalid email can't exist!
public function __construct(Email $email)
{
$this->email = $email; // Already validated β
}
}
// Create user
$user = new User(Email::fromString('TEST@example.com'));
// Email automatically normalized to "test@example.com"
// Try invalid email
$user = new User(Email::fromString('not-an-email')); // β Throws exception
π‘ Magic: Validate once at creation. Everywhere else, just use it.
βββββββββββββββββββββββββββββ
ποΈ More Real-World Examples
π° Money Value Object
β Before:
// What's the currency? Is it cents or dollars?
$order->setTotal(10000);
$order->setCurrency('USD');
// Bugs waiting to happen:
$total = $order->getTotal() + $discount; // Mixed currencies? π±
β
After:
final class Money
{
private function __construct(
private int $amount, // Always in cents
private string $currency
) {
if ($amount < 0) {
throw new InvalidMoneyException('Amount cannot be negative');
}
if (!in_array($currency, ['USD', 'EUR', 'GBP'])) {
throw new InvalidCurrencyException($currency);
}
}
public static function usd(int $cents): self
{
return new self($cents, 'USD');
}
public function add(Money $other): self
{
if ($this->currency !== $other->currency) {
throw new CurrencyMismatchException();
}
return new self(
$this->amount + $other->amount,
$this->currency
);
}
public function multiply(Percentage $percentage): self
{
return new self(
(int) ($this->amount * $percentage->toDecimal()),
$this->currency
);
}
}
// Usage
$price = Money::usd(10000); // $100.00
$discount = Money::usd(1500); // $15.00
$total = $price->add($discount); // Type-safe!
// This won't compile:
$total = $price->add("15.00"); // β Type error! β
π Address Value Object
final class Address
{
private function __construct(
private string $street,
private string $city,
private string $zipCode,
private string $country
) {
if (strlen($zipCode) < 4 || strlen($zipCode) > 10) {
throw new InvalidZipCodeException($zipCode);
}
}
public static function create(
string $street,
string $city,
string $zipCode,
string $country
): self {
return new self($street, $city, $zipCode, $country);
}
public function isSameCity(Address $other): bool
{
return $this->city === $other->city
&& $this->country === $other->country;
}
}
βββββββββββββββββββββββββββββ
π Eric Evans' Rule: When to Create a VO?
Not every field needs a VO. Here's the decision tree:
β CREATE a Value Object if:
| Condition | Example |
|---|---|
| Has business rules |
Email (format validation) |
| Has behavior |
Money::add(), DateRange::overlaps()
|
| Appears in multiple entities |
Money in Order, Invoice, Payment |
| Has business meaning |
Percentage, PhoneNumber
|
β DON'T create if:
| Condition | Example |
|---|---|
| Simple primitive |
quantity: int is fine |
| One-time use | sortOrder: int |
| Framework type works | \DateTimeImmutable |
| Just a label |
status: string (unless has behavior) |
π Real Entity Example
final class Order
{
private OrderId $id; // β
VO: unique identifier
private Email $customerEmail; // β
VO: validation + behavior
private Money $total; // β
VO: calculations
private Address $shippingAddress; // β
VO: complex value
private \DateTimeImmutable $createdAt; // β Framework type is enough
private int $itemCount; // β Simple primitive
private OrderStatus $status; // β
VO: if has behavior (state machine)
}
π¬ "Value Objects are not about wrapping primitives. They're about expressing business concepts."
β Eric Evans, Domain-Driven Design
βββββββββββββββββββββββββββββ
π₯ Before/After: Real Production Code
Scenario: E-commerce Discount System
β Before (Nightmare):
class Order
{
public function applyDiscount(float $percent): void
{
// What if percent is 150? Or -50? π±
$discount = $this->total * ($percent / 100);
$this->total -= $discount;
// Can total go negative? Who knows! π±
}
}
// Usage (bugs everywhere)
$order->applyDiscount(10); // 10%? 0.10? Who knows!
$order->applyDiscount(200); // Oops, negative total
$order->applyDiscount(-50); // Customer gets money? π₯
β
After (Rock Solid):
final class Percentage
{
private function __construct(private int $value)
{
if ($value < 0 || $value > 100) {
throw new InvalidPercentageException($value);
}
}
public static function fromInt(int $value): self
{
return new self($value);
}
public function toDecimal(): float
{
return $this->value / 100;
}
}
final class Money
{
public function applyDiscount(Percentage $discount): self
{
$discountAmount = (int) ($this->amount * $discount->toDecimal());
return new self(
max(0, $this->amount - $discountAmount), // Never negative
$this->currency
);
}
}
// Usage (impossible to misuse)
$discounted = $price->applyDiscount(Percentage::fromInt(10)); // Clear!
$invalid = Percentage::fromInt(150); // β Throws exception β
βββββββββββββββββββββββββββββ
π Value Objects Cheat Sheet
βββββββββββββββββββββββββββββββββββββββββββββββββββ
β Primitive β Value Object Mapping β
βββββββββββββββββββββββββββββββββββββββββββββββββββ€
β string email β Email β
β string phone β PhoneNumber β
β float price β Money β
β int percent β Percentage β
β string address β Address β
β string uuid β EntityId (OrderId, UserId) β
β array coords β Coordinates β
β string date β DateRange, Period β
βββββββββββββββββββββββββββββββββββββββββββββββββββ
Folder Structure:
Domain/
βββ ValueObject/
β βββ Email.php
β βββ Money.php
β βββ Percentage.php
β βββ Address.php
β βββ PhoneNumber.php
βββ Entity/
βββ Order.php
βββββββββββββββββββββββββββββ
π Start Small: Your First Value Object
Today: Pick your most-validated string (probably email)
Tomorrow: Create Email Value Object
This week: Replace all string $email with Email
Next week: Add Money, Percentage
Pro tip: New code only. Don't refactor everything at once.
βββββββββββββββββββββββββββββ
π¬ Discussion Time
What's the most annoying primitive type in your codebase? Is it string $price, array $address, or something else?
Drop your worst "primitive obsession" horror story in the comments! π
Bonus: What Value Object would save you the most debugging time?
Top comments (0)