This is a continuation to the Primitive Obsession and ValueObjects Overview
When dealing with primitive values, there are no restrictions on how they can be combined or used with other primitive values of the same type.
However, there may be an implied meaning attached to the primitive value, such as representing Celsius / Fahrenheit, or perhaps as a distance Meters / Feet.
As these are implied meanings, there is no compile time check / static analysis to prohibit interactions between values that should not otherwise be able to interact:
public final class Example {
public static void example() {
final double celsius = 10.0;
final double meters = 30.0;
// Means nothing... but will compile
final double result = celsius + meters;
}
}
Without explicitly attaching the meaning to the primitive value, it can be easy to accidentally misuse the value, resulting in difficult to find runtime errors.public final class Scenario {
public static void scenario() {
final double celsius = 10.0;
final double fahrenheit = 20.0;
final double result = celsius + ((5/9) * fahrenheit - 32);
}
}
In order to combine the two temperatures of different types, a conversion is required, as indicated in the example above.public final class Scenario {
public static void scenario() {
final double celsius = 10.0;
final double fahrenheit = 20.0;
final double result = celsius + Scenario.toCelsius(fahrenheit);
}
public static double toCelsius(final double fahrenheit) {
return (5/9) * (fahrenheit - 32);
}
}
This is a little bit better, however as time progresses and more functionality is required, it might make sense to create a separate Utils class to handle all functionality in one place:public final class TemperatureUtils {
public static double toCelsius(final double fahrenheit) {
return (5/9) - (fahrenheit - 32);
}
public static double toFahrenheit(final double celsius) {
return (celsius * (9/5)) + 32;
}
public static double addCelsiusAndFahrenheit(final double celsius, final double fahrenheit) {
return celsius + TemperatureUtils.toCelsius(fahrenheit);
}
public static double addFahrenheitAndCelsius(final double fahrenheit, final double celsius) {
return fahrenheit + TemperatureUtils.toFahrenheit(celsius);
}...
}
public final class Scenario {
public static void scenario() {
final double celsius = 10.0;
final double fahrenheit = 20.0;
final double result = TemperatureUtils.addCelsiusAndFahrenheit(celsius, fahrenheit);
}
}
Though this is an improvement, having a separate Utils class is still optional. It is up to the developer to be aware of the external helper class, and know when and how to use it properly:
public final class BadExample {
public static void badExample() {
final double celsius = 10.0;
final double fahrenheit = 20.0;
final double meters = 30.0;
final double result1 = celsius + fahrenheit; // 💥 KABOOM! conversion was forgotten
final double result2 = TemperaturUtils.addCelsiusAndFahrenheit(fahrenheit, celsius); // 💥 KABOOM! arguments were passed incorrectly
final double result3 = celsius / meters; // 💥 KABOOM! should not be possible
}
}
In the above example three errors are identifier.Each of these errors may be hard to find, as these are runtime errors, and at a quick glance may appear to be correct. None will throw an exception, but the values they produce are all wrong.
Another issue with the above Utils pattern operating on primitive values, is that it may not be necessarily clear what the type of the result will be. Without looking at the documentation or implementation it may be hard to understand, for example, if TemperatureUtils.addCelsiusAndFahrenheit returns a value in Celsius or a value in Fahrenheit since it only returns a double.
Runtime issues are hard to track down. Developer discipline to call the correct Utils at the correct time is hard to ensure. Finding incorrect uses during code reviews can be missed. These are all difficult problems that arise when trying to use primitives for everything.
Solution
Rather than using the primitive values directly throughout the codebase and having all the related functionality in a Utils class, it would be better to create a ValueObject class to wrap the primitive value, and restrict how it is used.
Even though all primitive values may be acceptable when constructing the ValueObject, HOW and WHERE these values are used, are intentionally restricted to help ensure the value is used correctly.
This is also a good step towards Domain Driven Design (DDD), as the ValueObject carries a more meaningful name than the primitive value within the system.
The ValueObjects for Celsius and Fahrenheit might look like:
public final class Celsius {
private final double value;
public Celsius(final double value) {
this.value = value;
}
// embed the conversion into the constructor
public Celsius(final Fahrenheit fahrenheit) {
this.value = (5/9) * (fahrenheit.unwrap() - 32);
}
public Celsius add(final Celsius celsius) {
return new Celsius(this.value + celsius.value);
}
public Celsius add(final Fahrenheit fahrenheit) {
return this.add(new Celsius(fahrenheit));
}
@Override
public boolean equals(final Object o) {
return (o instanceof Celsius c && this.value.equals(c.value))
|| (o instanceof Fahrenheit f && this.value.equals(new Celsius(f).value));
}
@Override
public int hashCode() {
return this.value.hashCode();
}
@Override
public String toString() {
return this.value + "°C";
}
// explicitly be clear about unwrapping the primitive value
public double unwrap() {
return this.value;
}
}
public final class Fahrenheit {
private final double value;
public Fahrenheit(final double value) {
this.value = value;
}
// embed the conversion into the construction
public Fahrenheit(final Celsius celsius) {
this.value = (celsius.unwrap() * (9/5)) + 32;
}
public Fahrenheit add(final Fahrenheit fahrenheit) {
return new Fahrenheit(this.value + fahrenheit.value);
}
public Fahrenheit add(final Celsius celsius) {
return this.add(new Fahrenheit(Celsius));
}
@Override
public boolean equals(final Object o)
return (o instanceof Fahrenheit f && this.value.equals(c.value))
|| (o instanceof Celsius c && this.value.equals(new Fahrenheit(c).value));
}
@Override
public int hashCode() {
return this.value.hashCode();
}
@Override
public String toString() {
return this.value + "°F";
}
// explicitly be clear about unwrapping the primitive value
public double unwrap() {
return this.value;
}
}
With the above ValueObject classes Celsius and Fahrenheit we are able to restrict how the values are used. By encapsulating the conversion within the class, and limiting what types can be used with the add methods, there no longer is a need for the "optional" TemperatureUtils helper class.diff
- final double result = TemperatureUtils.addCelsiusAndFahrenheit(celsius, fahrenheit)
+ final Celsius result = celsius.add(fahrenheit);
Likewise, unexpected uses and combinations are now caught by the compiler, removing runtime errors, as the application will not even compile.final Celsius celsius = new Celsius(10.0);
final double meters = 30.0; // still just a primitive
final ??? result = celsius.add(meters); // ❌ This is now a compilation error
Generally, all values do not interact with all other values, such as the case with celsius.add(meters) above. Restricting the usages within the ValueObject ensures that no unexpected uses can occur, since the compiler would catch it.
As the system evolves, and more functionality is needed (ex: celsius.minus(fahrenheit)), then add that functionality as needed. It is better to be more restrictive, and only add functionality or overloads as needed.
Conclusion
Even though the underlying primitive value may be unrestricted, it can be useful to restrict how that value is used, and how it interacts with other parts of the system.
By wrapping the primitive in a ValueObject, all of its associated functionality can be moved onto the class directly, rather than relying on an external Utils helper class. This helps to make an easily discoverable API for what the ValueObject can do. The compiler also takes on a more active role, helping to ensure no accidental misuses occur.
This is just one of the ways of taking advantage of the ValueObject pattern, and working towards a stronger system, and a step towards Domain Driven Development.
We'll have a part 3 to continue this discussion next month. Please check back often for updates and new articles.