Skip to content

Latest commit

 

History

History
400 lines (326 loc) · 9.72 KB

File metadata and controls

400 lines (326 loc) · 9.72 KB

ADR-003: Value Objects over Primitive Types

Status

Accepted

Context

In our booking domain, we need to represent several concepts:

  • Space: "padel", "pool", "gym"
  • Date: "2025-10-15"
  • Time Slot: 9, 10, 11, ..., 20
  • Status: "FREE", "RESERVED"

We need to decide how to model these in our domain layer.

Decision Drivers

  • Type safety: Prevent invalid values at compile/runtime
  • Testability: Each concept should be independently testable
  • Domain clarity: Code should read like business language
  • Encapsulation: Validation logic should live with the data
  • Immutability: Values shouldn't change after creation

Problem: Primitive Obsession

Anti-pattern: Using strings everywhere

class Booking
{
    private string $space;        // ❌ Any string is valid
    private string $date;         // ❌ "invalid" would be accepted
    private int $timeSlot;        // ❌ 99 would be accepted
    private string $status;       // ❌ "WHATEVER" would be accepted
}

// Usage problems:
$booking = new Booking('invalid-space', 'bad-date', 99, 'WRONG');  // ❌ Compiles fine!

Problems:

  1. No validation at domain level
  2. Validation scattered across controllers, services
  3. Tests must validate the same thing in multiple places
  4. Easy to make mistakes: Mix up parameter order
  5. No encapsulation: Validation logic is duplicated

Options Considered

Option 1: Keep using primitives + validate in controllers

class BookingController
{
    public function createBooking(Request $request)
    {
        $space = $request->get('space');
        if (!in_array($space, ['padel', 'pool', 'gym'])) {
            throw new BadRequestException();
        }
        // More validation...
    }
}

Pros:

  • ✅ Less code initially
  • ✅ Familiar pattern

Cons:

  • ❌ Validation only at entry point (controller)
  • ❌ Domain objects can be in invalid state
  • ❌ Must duplicate validation in tests
  • ❌ Not following Domain-Driven Design

Option 2: PHP 8.1+ Enums

enum Space: string
{
    case PADEL = 'padel';
    case POOL = 'pool';
    case GYM = 'gym';
}

Pros:

  • ✅ Type-safe
  • ✅ Built-in to PHP
  • ✅ IDE autocomplete

Cons:

  • ❌ Cannot add behavior or methods
  • ❌ Cannot customize validation logic
  • ❌ Limited for complex validations (like date format)
  • ❌ Not suitable for TimeSlot (range validation)

Option 3: Value Objects (Chosen)

final class Space
{
    private const VALID_SPACES = ['padel', 'pool', 'gym'];

    private string $value;

    public function __construct(string $value)
    {
        $value = trim($value);

        if (!in_array($value, self::VALID_SPACES, true)) {
            throw new \InvalidArgumentException(
                sprintf('Invalid space. Allowed: %s', implode(', ', self::VALID_SPACES))
            );
        }

        $this->value = $value;
    }

    public function value(): string
    {
        return $this->value;
    }

    public function equals(Space $other): bool
    {
        return $this->value === $other->value();
    }
}

Pros:

  • Fail fast: Invalid values cannot be created
  • Type safety: Cannot pass wrong type
  • Encapsulation: Validation lives with the data
  • Testable: Each VO has its own test suite
  • Immutable: Once created, value cannot change
  • Self-documenting: Clear what values are valid
  • DDD best practice: Recommended by Eric Evans

Cons:

  • ❌ More files to create
  • ❌ More boilerplate code

Decision

Use Value Objects for all domain concepts

Create immutable Value Objects for:

  1. Space - Enum-like with validation
  2. TimeSlot - Range validation (9-20)
  3. BookingDate - Date format validation
  4. BookingStatus - Factory methods for states

Implementation Patterns

Pattern 1: Enum-like Value Object (Space)

final class Space
{
    private const VALID_SPACES = ['padel', 'pool', 'gym'];

    public function __construct(string $value)
    {
        $value = trim($value);  // Normalize
        if (!in_array($value, self::VALID_SPACES, true)) {
            throw new \InvalidArgumentException(/*...*/);
        }
        $this->value = $value;
    }
}

Why:

  • Closed set of valid values
  • String-based (matches API contract)
  • Easy to serialize/deserialize

Pattern 2: Range Validation (TimeSlot)

final class TimeSlot
{
    private const MIN_HOUR = 9;
    private const MAX_HOUR = 20;

    public function __construct(int $hour)
    {
        if ($hour < self::MIN_HOUR || $hour > self::MAX_HOUR) {
            throw new \InvalidArgumentException(/*...*/);
        }
        $this->hour = $hour;
    }

    public function format(): string
    {
        return sprintf('%02d:00', $this->hour);  // Business logic in VO
    }
}

Why:

  • Business rule: "Operating hours are 9:00-20:00"
  • Validation ensures invariant
  • Formatting logic encapsulated

Pattern 3: Format Validation (BookingDate)

final class BookingDate
{
    public function __construct(string $value)
    {
        $date = \DateTime::createFromFormat('Y-m-d', $value);

        if (!$date || $date->format('Y-m-d') !== $value) {
            throw new \InvalidArgumentException('Invalid date format. Expected Y-m-d');
        }

        $this->value = $value;
    }
}

Why:

  • Enforces consistent date format across system
  • Prevents "2025-13-40" style errors
  • Format validation is complex, belongs in VO

Pattern 4: Factory Methods (BookingStatus)

final class BookingStatus
{
    private const FREE = 'FREE';
    private const RESERVED = 'RESERVED';

    private function __construct(string $value)  // Private!
    {
        $this->value = $value;
    }

    public static function free(): self
    {
        return new self(self::FREE);
    }

    public static function reserved(): self
    {
        return new self(self::RESERVED);
    }

    public function isFree(): bool
    {
        return $this->value === self::FREE;
    }
}

Why:

  • Factory methods prevent invalid states
  • Expressive: BookingStatus::free() vs new BookingStatus('FREE')
  • Behavior methods: isFree(), isReserved()

Benefits Realized

Benefit 1: TDD is Natural

// Test is trivial to write
public function it_throws_exception_for_invalid_space(): void
{
    $this->expectException(\InvalidArgumentException::class);
    new Space('invalid');
}

Result: 18 value object tests, all independent

Benefit 2: Impossible States are Impossible

$booking = new Booking(
    new Space('padel'),              // ✅ Guaranteed valid
    new BookingDate('2025-10-15'),   // ✅ Guaranteed valid format
    new TimeSlot(14)                 // ✅ Guaranteed in range 9-20
);

// This CANNOT compile:
$booking = new Booking('invalid', 'bad-date', 99);  // ❌ Type error!

Benefit 3: Business Logic Lives in Domain

$timeSlot = new TimeSlot(14);
echo $timeSlot->format();  // "14:00"

// Formatting logic is not in controller or view

Benefit 4: Self-Documenting Code

// Before (primitives):
public function findBookings(string $space, string $date): array  // ❌ What format?

// After (value objects):
public function findBookings(Space $space, BookingDate $date): array  // ✅ Crystal clear

Benefit 5: Refactoring is Safe

If we want to change allowed spaces:

// Only one place to change:
final class Space
{
    private const VALID_SPACES = ['padel', 'pool', 'gym', 'tennis'];  // ✅
}

// All tests validate this automatically
// All code using Space gets new validation for free

Trade-offs

Cost: More Files

Before: 0 files (just primitives)
After: 4 files (Space, TimeSlot, BookingDate, BookingStatus)

Worth it? YES

  • Each file is < 50 lines
  • Each file has single responsibility
  • Each file is independently testable

Cost: Verbose Construction

// Before:
$booking = new Booking('padel', '2025-10-15', 14);

// After:
$booking = new Booking(
    new Space('padel'),
    new BookingDate('2025-10-15'),
    new TimeSlot(14)
);

Worth it? YES

  • Prevents bugs at compile time
  • Self-documenting
  • Validates immediately

Consequences

Positive

  • 18 value object tests covering all validation rules
  • Type safety: Compiler catches mistakes
  • Fail fast: Invalid values cannot be created
  • Single Responsibility: Validation logic lives in one place
  • Testability: Each VO is independently tested
  • Domain clarity: Code reads like business rules

Negative

  • ❌ More files to create initially (~1 hour)
  • ❌ More verbose object construction
  • ❌ Learning curve for junior developers

Neutral

  • Value Objects are immutable (this is correct by design)
  • Must unwrap values for persistence ($space->value())

Validation: Have we achieved the goals?

  1. Type safety

    function book(Space $space) {}  // Cannot pass string
  2. Testability

    tests/Unit/Domain/ValueObject/  # 18 tests
  3. Domain clarity

    new Space('padel')  # Clear what this represents
  4. Encapsulation

    Space::__construct()  # Validation here, nowhere else
  5. Immutability

    final class Space  # Cannot extend or modify

Lessons Learned

  1. Write tests first: TDD naturally leads to Value Objects
  2. Start simple: Don't add behavior until needed
  3. Be consistent: All domain concepts should be VOs, not some
  4. Fail fast: Validate in constructor, not later
  5. Immutability is key: No setters, ever

References

  • Eric Evans: "Domain-Driven Design" (Chapter on Value Objects)
  • Martin Fowler: "Value Object" pattern
  • Vaughn Vernon: "Implementing Domain-Driven Design"
  • PHP The Right Way: https://phptherightway.com/