Poorly designed objects with getters and setters, a thorn in my side.
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.
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.
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);
Defining objects with behaviour and strict requirements will enable other developers to work with such objects with confidence.