DEV Community

Igor Nosatov
Igor Nosatov

Posted on

💎 Stop Using Primitive Types: Value Objects in PHP for Domain-Driven Design

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)