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)