DEV Community

Igor Nosatov
Igor Nosatov

Posted on

πŸ’Ž Value Objects: Stop Using Strings for Everything (Your Future Self Will Thank You)

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?
Enter fullscreen mode Exit fullscreen mode

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.
}
Enter fullscreen mode Exit fullscreen mode

⚠️ Common Disasters:

  • calculateDiscount(50, 100) β€” which is price, which is percentage?
  • "2024-03-15" vs "15-03-2024" β€” format confusion
  • null for 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)) { /* ... */ }
Enter fullscreen mode Exit fullscreen mode

βœ… 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;
    }
}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

πŸ’‘ 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? 😱
Enter fullscreen mode Exit fullscreen mode

βœ… 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! βœ…
Enter fullscreen mode Exit fullscreen mode

πŸ“ 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;
    }
}
Enter fullscreen mode Exit fullscreen mode

━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

πŸŽ“ 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)
}
Enter fullscreen mode Exit fullscreen mode

πŸ’¬ "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? πŸ”₯
Enter fullscreen mode Exit fullscreen mode

βœ… 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 βœ…
Enter fullscreen mode Exit fullscreen mode

━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

🎁 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           β”‚
β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜
Enter fullscreen mode Exit fullscreen mode

Folder Structure:

Domain/
β”œβ”€β”€ ValueObject/
β”‚   β”œβ”€β”€ Email.php
β”‚   β”œβ”€β”€ Money.php
β”‚   β”œβ”€β”€ Percentage.php
β”‚   β”œβ”€β”€ Address.php
β”‚   └── PhoneNumber.php
└── Entity/
    └── Order.php
Enter fullscreen mode Exit fullscreen mode

━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

πŸš€ 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?

DDD #ValueObjects #CleanCode #PHP #SoftwareDesign #DomainDrivenDesign

Top comments (0)