Categorias
PHP

Introducing value objects in PHP

Domain-Driven Design (DDD) is a software design philosophy with one crucial concept: the structure and language of software code (class names, class methods, class variables) should match the business domain. To attend to this concept, DDD presents Value Objects, which, in practice, represents an object similar to a primitive type but should be modelled after the domain's business rules.

What does it mean?

(Checkout code at https://github.com/rafaelbernard/blog-value-objects)

Value Objects are first described in Evans' Domain-Driven Design book, and further explained in Smith and Lerman's Domain-Driven Design Fundamentals course. It is an immutable type that is distinguishable only by the state of its properties. Unlike an Entity1, a class type with a unique identifier and remains distinct even if its properties are otherwise identical, two Value Objects with the same properties can be considered equal.

We would use Value Object classes to represent a type strictly and encapsulate the validation rules of that type. Think of age or a card from a poker deck. They can seem sufficiently represented by primitive types such as integer and string, but in reality, they are managed by strict business rules2. For example, you would like to ensure that a particular age value is a non-negative integer. Or a picked card has a value not greater than 10, not 1, i.e., there is a range of allowed numbers or unique letters combined within four allowed suits.

To translate it to code, you could think of a Person class as a guest application to map a person's name and age. Usually, we would say a name is a string and age is an int.

class Person
{
    private string $name;
    private int $age;

    public function __constructor(string $name, int $age)
    {
        $this->name = $name;
        $this->age = $age;
    }
}

Although we can think that the specification above is good and we are just instantiating a Person object with all the required attributes, we may have some problems with the implementation because negative values are also int and would be allowed:

$personOk = new Person('John Doe', 18);
$notARealPerson = new Person('Benjamin Button', -18); // -18 year? no way!

The code above will not fail and is very common that some business logic is performed to assert age will never be negative:

// if from a form
$age = (int) $_POST['age'];

if ($age < 0) {
    throw new \Exception('Age could not be negative');
}

// but you need to copy the check everywhere a Person class is used. What if someone overlooks it?
$person = new Person('John Doe', $age);

When using Value Objects, you leverage your class with the exact type you need, correctly applying business logic. I will give more details later, but a hint of how the Person class would be with a Value Object:

use App\Domain;

class Person
{
    private string $name;
    private Age $age;

    public function __construct(string $name, Age $age)
    {
        $this->name = $name;
        $this->age = $age;
    }
}

And now we are always sure that Person objects will have a valid age. Bear with me and understand how Age Value Object class should look.

Samples of Value Objects

You may have already realized that we have many cases where we need similar things on every application. They are used to validate patterns to an expected format, a set of possible values to simulate an enum (only present on PHP 8) or to extend this set of values to be validated against some more rules.

I will be using PHP 7.4 compatible code in this blog. If you have applications using older versions, changing them should not be too complicated. Let me know in the comments if you need samples about how to write to an older version.

Some examples:

Validating a format:

Our Age class should be implemented as:

<?php

namespace App\Domain;

class Age
{
    private int $value;

    /**
     * @param int $value
     */
    public function __construct(int $value)
    {
        if (!$value < 0) {
            throw new \UnexpectedValueException('Negative numbers are not a valid age.');
        }

        $this->value = $value;
    }

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

Or a richer example with card suits:

<?php

namespace App\Domain;

class CardSuit
{
    const HEARTS = 'H';
    const DIAMONDS = 'D';
    const CLUBS = 'C';
    const SPADES = 'S';

    const SUITS = [
        self::HEARTS,
        self::DIAMONDS,
        self::CLUBS,
        self::SPADES,
    ];

    private string $value;

    public function __construct(string $value)
    {
        if (!in_array($value, self::SUITS)) {
            throw new \InvalidArgumentException("`$value` is not a valid card suit.");
        }

        $this->value = $value;
    }

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

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

Validating a set and its rules:

Observe rules when checking grade with isAlumni() from a given ClassYear. Ideally, we would have a Grade class, but I kept it more straightforward to simplify understanding the check, and I have a similar example with poker cards classes above.

<?php

namespace App\Domain;

class ClassYear
{
    private int $value;

    /**
     * @param int $value
     */
    public function __construct(int $value)
    {
        // For class year, our business rules is from 1901-2999
        if (!preg_match('/^((19\d{2})|(2)\d{3})$/', $value)) {
            throw new \InvalidArgumentException('Invalid year');
        }

        $this->value = $value;
    }

    public static function fromNow(): self
    {
        return new self(date('Y'));
    }

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

    public function isAlumni(): bool
    {
        return date('Y') - $this->value >= 13;
    }
}

Enum-like validation:

Observe that it encapsulates some Enum values, but also a simple rule (isGreaterThan), and it enriches the type with a simple business rule support that the application can also use.

<?php

namespace App\Domain;

class CardRank
{
    const ACE = 'A';
    const KING = 'K';
    const QUEEN = 'Q';
    const JACK = 'J';
    const TEN = 'X';
    const NINE = '9';
    const EIGHT = '8';
    const SEVEN = '7';
    const SIX = '6';
    const FIVE = '5';
    const FOUR = '4';
    const THREE = '3';
    const TWO = '2';

    const RANKS = [
        self::ACE,
        self::KING,
        self::QUEEN,
        self::JACK,
        self::TEN,
        self::NINE,
        self::EIGHT,
        self::SEVEN,
        self::SIX,
        self::FIVE,
        self::FOUR,
        self::THREE,
        self::TWO
    ];

    const WEIGHTS = [
        self::ACE => 20,
        self::KING => 13,
        self::QUEEN => 12,
        self::JACK => 11,
        self::TEN => 10,
        self::NINE => 9,
        self::EIGHT => 8,
        self::SEVEN => 7,
        self::SIX => 6,
        self::FIVE => 5,
        self::FOUR => 4,
        self::THREE => 3,
        self::TWO => 2
    ];

    private string $value;

    public function __construct(string $value)
    {
        if (!in_array($value, self::RANKS)) {
            throw new \InvalidArgumentException("`$value` is not a valid card rank.");
        }

        $this->value = $value;
    }

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

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

    public function weight()
    {
        return self::WEIGHTS[$this->value];
    }

    public function isGreaterThan(CardRank $cardRank): bool
    {
        return $this->weight() > $cardRank->weight();
    }

    // Some static helper functions can be created to be more readable

    public static function two(): CardRank
    {
        return new self(self::TWO);
    }

    public static function ace(): CardRank
    {
        return new self(self::ACE);
    }
}

When to use it

Create and use the Value Objects whenever you see that it fits "encapsulate the business rules for a given type" and "it represents an object similar to a primitive type". This will directly understand the expected type of code and instantly validate capabilities.

Some examples:

// a
$person = new Person('John Doe', new Age(20));
public function saveGuest(Person $person);

// b
$currentUserClassYear = ClassYear::fromNow();
if (!$currentUserClassYear->isAlumni()) {
    // do something for a non-alumni user
}

// c
$pickedCard = new Card(new CardSuit(CardSuit::SPADES), new CardRank(CardRank::ACE));

// d
$aceSpade = new Card(CardSuit::spades(), CardRank::ace());
$twoSpade = new Card(CardSuit::spades(), CardRank::two());
if ($aceSpace->isGreaterThan($twoSpade)) {
    // do something when greater, such as sum the weight to count points
}

A code-base that uses Value Objects avoids repetition code to validate a type that is not represented by native types, improves readability, and keeps consistency about business rules (no one could overlook or forget to "copy the validation code check"). Value Objects has a built-in validation, making it superior to validation classes and creating a relationship with your business domain rules. It then keeps the code clean and lean.

You can see all related code at https://github.com/rafaelbernard/blog-value-objects.

Glossary

  1. Entity: An entity may be defined as a thing capable of an independent existence that can be uniquely identified. An entity is an abstraction from the complexities of a domain. When we speak of an entity, we normally speak of some aspect of the real world that can be distinguished from other aspects of the real world. [(from Wikipedia)]
  2. Business rule: A business rule defines or constrains some aspect of business and always resolves to either true or false. Business rules are intended to assert business structure or to control or influence the behavior of the business.
  3. Object-oriented programming (OOP) is a programming paradigm based on the concept of "objects", which can contain data and code: data in the form of fields (often known as attributes or properties), and code, in the form of procedures (often known as methods).

3 respostas em “Introducing value objects in PHP”

Deixe uma resposta

Esse site utiliza o Akismet para reduzir spam. Aprenda como seus dados de comentários são processados.