Defining strict domain objects

Poorly designed objects with getters and setters, a thorn in my side.

Objective

Frequently we encounter poorly designed objects with setters and getters without any behaviour or any restrictions. In this post i will show you how we can improve such "soul-less" objects in to something that defines good behaviour and restricts your interaction with such objects.

Object without behaviour

Below we have a User class which does not contain any behaviour or restrictions with typical getters and setters:

class User
{
    private int $id;
    private string $firstName;
    private string $lastName;
    private int $loyaltyPoints;
    private bool $active;

    public function __construct(string $firstName, string $lastName, int $loyaltyPoints, bool $active)
    {
        $this->firstName = $firstName;
        $this->lastName = $lastName;
        $this->loyaltyPoints = $loyaltyPoints;
        $this->active = $active;
    }

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

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

    public function setFirstName(string $firstName): void
    {
        $this->firstName = $firstName;
    }

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

    public function setLastName(string $lastName): void
    {
        $this->lastName = $lastName;
    }

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

    public function setLoyaltyPoints(int $loyaltyPoints): void
    {
        $this->loyaltyPoints = $loyaltyPoints;
    }

    public function isActive(): bool
    {
        return $this->active;
    }

    public function setActive(bool $active): void
    {
        $this->active = $active;
    }
}

Object above is presenting us with a lot of issues from the get-go. You cannot rely on such objects, because you can set values to any of the properties however you like. For example in our domain if the User is disabled, loyaltyPoints should be set to 0 and we can not set loyaltyPoints again, or at least until we activate User again. This object does not have any behaviour or restrictions that would provide the desired behaviour.

Objects with behaviour

Below is an improved version of the User class that we defined above. Three value objects replaced what previously were properties without any behaviour. User identifier was abstracted to a UserId, firstName and lastName were grouped together into a Name VO and loyaltyPoints into LoyaltyPoints VO with behaviour. For example if User was inactive for a while, we can deactivate it with deactivate() method which will also reset its loyalty points back to zero because thats what the domain behaviour should be. If we try to access loyalty points for this User with getLoyaltyPoints() method, an exception will be raised. If we want to again add loyalty points we would have to activate the User.

class User
{
    private UserId $userId;
    private Name $name;
    private LoyaltyPoints $loyaltyPoints;
    private bool $active = true;

    public function __construct(UserId $userId, Name $name)
    {
        $this->userId = $userId;
        $this->name = $name;
        $this->loyaltyPoints = LoyaltyPoints::create();
    }

    public function getUserId(): UserId
    {
        return $this->userId;
    }

    public function getName(): Name
    {
        return $this->name;
    }

    public function setName(Name $name): void
    {
        $this->name = $name;
    }

    public function getLoyaltyPoints(): LoyaltyPoints
    {
        if (!$this->active) {
            throw new RuntimeException('Loyalty points cannot be accessed because user is deactivated.');
        }

        return $this->loyaltyPoints;
    }

    public function isActive(): bool
    {
        return $this->active;
    }

    public function activate(): void
    {
        if ($this->active) {
            throw new RuntimeException('User is already active.');
        }

        $this->active = true;
    }

    public function deactivate(): void
    {
        if (!$this->active) {
            throw new RuntimeException('User is already deactivated.');
        }

        $this->active = false;
        $this->loyaltyPoints = $this->loyaltyPoints->resetToZero();
    }
}

UserId is an abstraction of the User identifier, converting an int identifier or any other scalar value into an object makes changing its type a lot easier in the future if for example you want to change from auto incremented ids generated by your RDS to a Uuid. It also means that you can type hint for UserId on all methods in the code base which makes it less error prone, because you cannot pass through just a random int, well you can but you would have to create a dedicated UserId which would make you think if this is the correct way of doing things. If you combine both reasons to use this abstractions for ids, the code base will be type hinted with UserId everywhere which means no refactoring if you plan to change its type in the future.

class UserId
{
    private int $id;

    public function __construct(int $id)
    {
        if ($id < 1) {
            throw new RuntimeException('Provided invalid value for user id. Must be greater than 0.');
        }

        $this->id = $id;
    }

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

Name is a value object created from first and last name. Restriction was added to this VO, it cannot be created if provided firstName or lastName is shorther than 2 characters.

class Name
{
    private string $firstName;
    private string $lastName;

    public function __construct(string $firstName, string $lastName)
    {
        if (strlen($firstName) < 2 || strlen($lastName) < 2) {
            throw new RuntimeException('First and last name must contain at least 2 characters.');
        }

        $this->firstName = $firstName;
        $this->lastName = $lastName;
    }

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

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

Visibility of constructor of LoyaltyPoints VO was set to private because we want to control how this VO can be constructed. A static method called create was added, which is the "entry" point into interaction with this VO. When we call LoyaltyPoints::create() method, it will create this VO with default value which is 0, now that we constructed this object we have two methods with which we can modify its state, received(int $value) and resetToZero(). The first will add the points that user received during his last purchase and the points that he already had, while the second will reset it to 0 if the User was inactive for a while.

class LoyaltyPoints
{
    private int $value;

    private function __construct(int $value)
    {
        if ($value < 0) {
            throw new RuntimeException(
                'Provided invalid value for loyalty points. Must be equal or greater than 0.'
            );
        }

        $this->value = $value;
    }

    public static function create(): self
    {
        return new self(0);
    }

    public function received(int $value): self
    {
        return new self($this->value + $value);
    }

    public function resetToZero(): self
    {
        return new self(0);
    }
}

Examples of interacting with such object:

// User registers and is persisted
$user = new User(new UserId(1), new Name('Foo', 'Bar'));
$this->userRepository->add($user);

// User buys a product and receives 15 loyalty points
$user->getLoyaltyPoints()->received(15);
$this->userRepository->add($user);

// User was deactivated because he was not active for a while
$user->deactivate();
$this->userRepository->add($user);

// Throws an exception, because user was deactivated so we cannot access his loyalty points
$user->getLoyaltyPoints();

// User was activated because he purchased a product after a while
$user->activate();

// We can access loyalty points because user was activated and increase its value because of the recent purchase
$user->getLoyaltyPoints()->increaseBy(5);
$this->userRepository->add($user);

Conclusion

Defining objects with behaviour and strict requirements will enable other developers to work with such objects with confidence.