Table of contents
- Introduction
- Different kinds of value objects
- Factory methods & private constructors
- Alternatives to exceptions
- Conclusion
Introduction
In the previous article, we explored the power of Value Objects in improving code quality, system robustness, and minimizing the need for extensive validation. Now, let's dive deeper to enhance our understanding and usage of this crucial tool.
Different kinds of value objects
When dealing with Value Objects, it's beneficial to classify them into different types based on their complexity. In my experience, I've identified three main types:
- Simple value objects
- Complex value objects
- Composite value objects
There could be a fourth, but it would essentially be a mix of these three types.
Simple Value Object
Simple Value Objects encapsulate a single value, often representing a primitive value or basic concept within your domain. These objects are ideal for straightforward attributes or measurements.
Let's take the Age
Value Object introduced in the previous article as an example:
readonly final class Age
{
public function __construct(public int $value)
{
$this->validate();
}
public function validate(): void
{
($this->value >= 18)
or throw InvalidAge::adultRequired($this->value);
($this->value <= 120)
or throw InvalidAge::matusalem($this->value);
}
public function __toString(): string
{
return (string)$this->value;
}
public function equals(Age $age): bool
{
return $age->value === $this->value;
}
}
In this example, Age is a Simple Value Object that represents a person's age. It encapsulates a single integer value and includes a validation mechanism to ensure the age falls within a reasonable range.
The __toString
method allows easy conversion to a string, and the equals
method compares two Age objects for equality.
When creating Simple Value Objects, consider the following guidelines:
Single Responsibility:
Keep the Value Object focused on representing a single concept or attribute, often corresponding to a primitive value.Immutability:
Once created, a Simple Value Object should not be altered. Any changes should result in the creation of a new instance.Validation:
Include validation logic within the constructor to ensure the object is always in a valid state.String Representation:
Implement the__toString
method for convenient string conversion when needed.Equality Check:
Provide anequals
method to compare two instances for equality.
By adhering to these guidelines, you create Simple Value Objects that enhance the clarity, stability, and reliability of your code.
Complex Value Object
While Simple Value Objects encapsulate a single value, Complex Value Objects handle more intricate structures or multiple attributes, forming a richer representation within your domain. These objects are well-suited for modelling complex concepts or aggregations of data.
Consider the Coordinates Value Object:
readonly final class Coordinates
{
public function __construct(
public float $latitude,
public float $longitude
)
{
$this->validate();
}
private function validate(): void
{
($this->latitude >= -90 && $this->latitude <= 90)
or throw InvalidCoordinates::invalidLatitude($this->latitude);
($this->longitude >= -180 && $this->longitude <= 180)
or throw InvalidCoordinates::invalidLongitude($this->longitude);
}
public function __toString(): string
{
return "Latitude: {$this->latitude}, Longitude: {$this->longitude}";
}
public function equals(Coordinates $coordinates): bool
{
return $coordinates->latitude === $this->latitude
&& $coordinates->longitude === $this->longitude;
}
}
In this example, the Coordinates Value Object represents geographic coordinates with latitude and longitude. The constructor ensures the object's validity, validating that latitude falls within the range [-90, 90] and longitude within [-180, 180]. The __toString
method provides a readable string representation, and the equals
method compares two Coordinates objects for equality.
When creating Complex Value Objects, consider the following guidelines:
Structured Representation:
Model the object to reflect the complexity and structure of the corresponding domain concept.Validation:
Implement validation logic within the constructor to ensure the object is always in a valid state.String Representation:
Include a meaningful__toString
method for better readability and debugging.Equality Check:
Provide anequals
method to compare two instances for equality.
This approach allows you to create Complex Value Objects that effectively represent intricate concepts within your application, such as geographic coordinates in this case.
Although not obvious in this example, Complex Value Objects often require more detailed checks. It's not just about individual values but also how they relate to each other.
Consider this example:
readonly final class PriceRange
{
public function __construct(
public int $priceFrom,
public int $priceTo
) {
$this->validate();
}
private function validate(): void
{
($this->priceTo >= 0)
or throw InvalidPriceRange::positivePriceTo($this->priceTo);
($this->priceFrom >= 0)
or throw InvalidPriceRange::positivePriceFrom($this->priceFrom);
($this->priceTo >= $this->priceFrom)
or throw InvalidPriceRange::endBeforeStart($this->priceFrom, $this->priceTo);
}
// ...(rest of the methods)
}
In this case, even if each price is fine on its own, we need to ensure that the priceTo
must come after or is the same as the priceFrom
.
Composite Value Object
Composite Value Objects are powerful structures that combine multiple Simple or Complex Value Objects into a cohesive unit, representing more intricate concepts within your domain. This allows you to build rich and meaningful abstractions.
Let's illustrate this with an example using an Address
Value Object:
readonly final class Address
{
public function __construct(
public Street $street,
public City $city,
public PostalCode $postalCode
) {}
public function __toString(): string
{
return "{$this->street}, {$this->city}, {$this->postalCode}";
}
public function equals(Address $address): bool
{
return $address->street->equals($this->street)
&& $address->city->equals($this->city)
&& $address->postalCode->equals($this->postalCode);
}
}
In this example, the Address
Composite Value Object is composed of Street
, City
, and PostalCode
. Each sub-object encapsulates a single value, and together they form a more comprehensive representation of an address.
The __toString
method provides a readable string representation, and the equals
method compares two Address objects for equality.
When creating Composite Value Objects, consider the following guidelines:
Composition:
Assemble multiple Simple or Complex Value Objects to create a more intricate structure.Abstraction:
Represent complex concepts within your domain using a composite structure.String Representation:
Include a meaningful__toString
method for better readability and debugging.Equality Check:
Provide anequals
method to compare two instances for equality.
In many cases, validating a Composite Value Object is not necessary as its validity is already ensured by its components. However, just like in Complex Value Objects, there might be scenarios where logic demands validation across different properties of the object. In such cases, validation is, of course, required.
Factory methods & Private constructors
In the examples presented so far, we've explored relatively simple value objects. However, everyday development introduces challenges when value objects have internal representations differing from their externals.
To illustrate this challenge, let's consider the DateTime concept. The date "24th December 2023, 4:09:53 PM, Rome time zone" can be represented in various ways, such as seconds since January 1, 1970, or as an RFC3339 string.
Unlike languages like Java or C#, PHP lacks constructor overloading. Here, the factory method design pattern, employing one or more static methods, becomes invaluable for controlled object instantiation.
Let's take a closer look at this value object:
class DateTimeValueObject
{
private DateTimeImmutable $dateTime;
private function __construct(DateTimeImmutable $dateTime)
{
$this->dateTime = $dateTime;
}
// Factory method to create from timestamp
public static function createFromTimestamp(int $timestamp): self
{
($timestamp >= 0) or InvalidDateTime::invalidTimestamp($timestamp);
$dateTime = new DateTimeImmutable();
$dateTime = $dateTime->setTimestamp($timestamp);
return new self($dateTime);
}
// Factory method to create from RFC3339 string
public static function createFromRFC3339(string $dateTimeString): self
{
$dateTime = DateTimeImmutable::createFromFormat(DateTime::RFC3339, $dateTimeString);
($dateTime !== false) or throw new InvalidDateTime::invalidRFC3339String($dateTimeString);
return new self($dateTime);
}
public static function createFromParts(int $year, int $month, int $day, int $hour, int $minute, int $second, string $timezone): self
{
(checkdate($month, $day, $year) && self::isValidTime($hour, $minute, $second)) or throw InvalidDateTime::invalidDateParts($year, $month, $day, $hour, $minute, $second, $timezone);
$dateTime = new DateTimeImmutable();
$dateTime = $dateTime
->setDate($year, $month, $day)
->setTime($hour, $minute, $second)
->setTimezone(new DateTimeZone($timezone));
return new self($dateTime);
}
private static function isValidTime(int $hour, int $minute, int $second): bool
{
return ($hour >= 0 && $hour <= 23) && ($minute >= 0 && $minute <= 59) && ($second >= 0 && $second <= 59);
}
public static function now(): self
{
return new self(new DateTimeImmutable());
}
public function getDateTime(): DateTimeImmutable
{
return $this->dateTime;
}
// __toString & equals methods
}
// Usage examples
$dateTime1 = DateTimeValueObject::createFromTimestamp(1703430593);
$dateTime2 = DateTimeValueObject::createFromRFC3339('2023-12-24T16:09:53+01:00');
$dateTime3 = DateTimeValueObject::createFromParts(2023, 12, 24, 16, 9, 53, 'Europe/Rome');
$dateTime4 = DateTimeValueObject::now();
Let's focus on some details:
Constructor Accessibility: In this example, the constructor is marked as private, restricting instantiation to within the class itself. However, it's essential to note that this is a design choice, not a strict requirement. Constructors can also be public, depending on the desired encapsulation and usage patterns. The private constructor here emphasizes controlled instantiation through factory methods, providing a clear interface for creating instances.
Factory Methods with Input Validation: Each factory method includes input validation to ensure the integrity of the provided data before creating the
DateTimeValueObject
. Whether the constructor is private or public, the factory methods act as gatekeepers, enforcing validation rules.createfromParts Method: This method showcases the flexibility of creating a
DateTimeValueObject
by specifying individual parts. The input validation within this method ensures that the constructed object reflects a valid date and time.now Method: The
now
method exemplifies a common factory method for creating instances representing the current date and time. It leverages theDateTimeImmutable
class internally to capture the current moment.__toString & equals Methods: While not explicitly demonstrated in this example, implementing
__toString
for obtaining a string representation andequals
for comparing instances are typical practices.
This approach highlights the versatility of using private or public constructors based on design preferences, reinforcing the idea that design patterns accommodate varying needs and choices.
Another interesting use of the factory method is to simplify the instantiation of a composite value object, like the Address Value Object seen before.
readonly final class Address
{
private function __construct(
public Street $street,
public City $city,
public PostalCode $postalCode
) {}
public static function create(
string $street,
string $city,
string $postalCode
): Address
{
return new Address(
new Street($street),
new City($city),
new PostalCode($postalCode)
);
}
// ... (rest of the methods)
}
As mentioned before, the private constructor keeps things tidy by forcing developers to use only the create
to obtain a new instance of the Value Object.
Also, with PHP 8, we can use a very cool trick:
$data = [
'street' => 'Via del Colosseo, 10',
'city' => 'Rome',
'postalCode' => '12345'
];
$address = Address::create(...$data);
This concise PHP 8 trick utilizes named arguments and array spread operator, showcasing a succinct and expressive method for object instantiation.
Alternatives to exceptions
Some people might have reservations about using exceptions as they interrupt the execution flow and, if not handled, can create issues.
However, there are alternative, more functional approaches that can come to our aid.
Either
For those unfamiliar with the concept of Either
, we could succinctly (and poorly) describe it as a type that can be either the right value or not (and the opposite of right is left).
If you wanna know more, take a look here or here.
In a simplified implementation, it might look like this:
/**
* @template L
* @template R
*/
final class Either
{
/**
* @param bool $isRight
* @param L|R $value
*/
private function __construct(private bool $isRight, private mixed $value)
{
}
/**
* @param L $value
* @return Either<L, R>
*/
public static function left(mixed $value): Either
{
return new self(false, $value);
}
/**
* @param R $value
* @return Either<L, R>
*/
public static function right(mixed $value): Either
{
return new self(true, $value);
}
/**
* @return bool
*/
public function isRight(): bool
{
return $this->isRight;
}
/**
* @return L|R
*/
public function getValue(): mixed
{
return $this->value;
}
}
Now, let's apply Either
in our Address
Value Object creation:
readonly final class Address
{
// ... (rest of the methods)
/**
* @returns Either<InvalidValue,Address>
*/
public static function create(
string $street,
string $city,
string $postalCode
): Either
{
try {
return Either::right(new Address(
new Street($street),
new City($city),
new PostalCode($postalCode)
));
} catch (InvalidValue $error) {
return Either::left($error);
}
}
// __toString & equals methods
}
Handling the result:
$address = Address::create('', '', '');
if ($address->isRight()) {
// do stuff in case of success
}
else {
// do stuff in case of error
/** @var InvalidValue $error */
$error = $address->getValue();
echo "Error: {$error->getMessage()}";
}
This approach provides a flexible way to manage outcomes, allowing distinct handling paths for success and error scenarios.
While several libraries implement Eithers in PHP, the lack of generics requires heavy use of static analysis tools, like PSalm or PHPStan. That's why sometimes might be tricky working with types.
Union types
Alternatively, PHP 8.0 introduced the concept of Union Types. Similar to the Either example, the create method returns two possible values:
readonly final class Address
{
// ... (rest of the methods)
public static function create(
string $street,
string $city,
string $postalCode
): InvalidValue|Address
{
try {
return new Address(
new Street($street),
new City($city),
new PostalCode($postalCode)
);
} catch (InvalidValue $error) {
return $error;
}
}
// __toString & equals methods
}
Handling the result:
$address = Address::create('', '', '');
if ($address instanceof InvalidValue) {
// do stuff in case of error
echo "Error: {$address->getMessage()}";
}
else {
// do stuff in case of success
}
When it comes to handling errors in PHP, there is no one-size-fits-all solution. The decision between using Either and Union Types depends on your project's specific needs.
Either provides a granular approach, enabling you to distinctly manage different outcomes. It emphasizes a structured and explicit error-handling strategy.
Union Types, on the other hand, leverage the language's built-in capabilities and simplify the syntax. This approach might be more aligned with the philosophy of "let it fail fast" by handling errors directly where they occur.
In conclusion, choosing the right error-handling approach in PHP involves thoughtful consideration of your project's context and needs. Either and Union Types are valuable tools, providing flexibility to tailor your strategy. The key is selecting an approach that aligns seamlessly with your project's philosophy, promoting clarity, maintainability, and resilience.
Conclusion
In wrapping up our exploration of Value Objects in PHP, we've covered various aspects to enhance your understanding and usage of this important tool.
We started by looking at Simple Value Objects, which represent basic concepts in your code. These objects encapsulate single values and come with guidelines like keeping them focused, making them immutable, ensuring validation, having a string representation, and providing a method for equality checks. By sticking to these principles, we can create clear and reliable code.
Moving on to Complex Value Objects, we tackled structures with more intricacies. These objects handle multiple attributes or complex structures, modeling richer concepts in your domain. The guidelines for complex value objects involve representing the domain concept well, implementing validation, having a meaningful string representation, and providing a method for equality checks.
The journey peaked with Composite Value Objects, where we saw the combination of multiple simple or complex value objects into a unified structure. This allows us to represent even more complex concepts, like addresses in our example. The guidelines here involve assembling different value objects, abstracting complex concepts, ensuring a readable string representation, and providing a method for equality checks.
Next, we explored Factory Methods & Private Constructors. We saw how these can be beneficial when dealing with value objects with internal representations differing from their externals. The DateTimeValueObject served as an example, showcasing the use of factory methods for controlled object instantiation. The flexibility of using private or public constructors was highlighted, emphasizing design choices.
In our final leg, we looked at Alternatives to Exceptions, introducing the Either
type and PHP 8.0's Union Types. Both these approaches offer different ways of handling errors. Either
provides a structured strategy, and Union Types simplify syntax for a "fail fast" philosophy.
In conclusion, as you navigate PHP development, choosing between Either
and Union Types depends on your project's needs. Both are valuable tools, offering flexibility to tailor your error-handling strategy. The key is selecting approaches that align with your project's context, ensuring code clarity, maintainability, and resilience. As you explore these possibilities, may your code be strong, your abstractions meaningful, and your decisions well-informed. Happy coding!
Top comments (0)