Restricting Values with ValueObjects
Primitive values and ValueObjects Continued: Restricting which values are accepted
In the last article, we explored how we can restrict how and where a value is used.
In this article we will focus on restricting which values are acceptable.
Primitive values (String / Integer / etc), as stated several times now, are unbound. However, most times our domain does have bounds and restrictions.
Scenario
As an example, lets say we are dealing with age, and that it has a requirement to never be less than zero (it can not be a negative number).
final var age = 10;
Typically, throughout the rest of the code base any other functions that would use the age would perform a check to ensure the value meets expectations. Or in some cases, the other functions would blindly "trust" that the value is correct and perform no checks at all (which could potentially lead to runtime issues in some cases).
final var age = -5;
...
// Example 1
public static String someDescription1(final int age) {
// explicit check
if (age < 0) { throw new RuntimeException("The age cannot be less than 0."); }
return "The user is " + age + " years old.";
}
// Example 2
public static String someDescription2(final int age) {
// implicitly trust
return "The user is " + age + " years old.";
}
In these examples, the validation is now a burden on the consuming functions. Each consuming function must re-implement over and over the same validation check. This is a sort of cross-cutting concern, in that each consuming function has to continuously validate it's input parameters, which detracts from the actual implementation and logic of the function.
The second example shows how a developer may forget, or choose not to validate at all, as it can become tedious to perform the same check over and over again. This unchecked input would in this case result in "The user is -5 years old." which is an incorrect statement. These unchecked functions can produce weird results, or result in an exception thrown which can be hard to trace down.
Solution
In this simple example we could embed the validation logic into the construction of a ValueObject so that the value will always be valid, relieving the burden of validation from all consuming functions.
A very simple and basic implementation could look like:
@Getter
@EqualsAndHashCode
public final class Age {
private final int value;
public Age(final int value) {
if (value < 0) { throw new RuntimeException("The value cannot be less than 0."); }
this.value = value;
}
}
This would allow us to refactor the above example so that it accepts Age instead of int:
// will throw an exception here, upon construction, and therefore would never enter the function below
final var age = new Age(-5);
public static String someDescription(@NonNull final Age age) {
// no more validation required in this function. Age always guaranteed to be zero or more.
return "The user is " + age.getValue() + " years old.";
}
Now, as we can see, the validation occurs within the construction of Age. This means that if the Age object is actually constructed, then it is guaranteed to have satisfied all requirements. Therefore, any consuming function later on does not need to perform any validation upon the Age value. It is a fact that we can embed within the type system.
This pattern satisfies what is known as the Curry-Howard Correspondence. There are many articles already out there on the internet, so I will not spend too much time on it.
Essentially, this is explained as "Proof Through Construction". If the object can be successfully created, then it must have satisfied all requirements. If the object could not successfully be created (an error or exception occurs), then the proof is not valid. Understanding this concepts allows us to embed "proofs" into the type system, offloading more onto the compiler.
Any consuming function or logic that uses an Age type is guaranteed to have a value that is zero or greater.
The cross-cutting validation can be removed, and likely unit tests can be simplified as well. Unit tests for validating the value of Age only need to be written once against the Age class.
Instead of "implicitly trusting" that the value is valid, by embedding the validation into the constructor we can confidently trust the value will be correct. If we have an instance of Age, then it is "proven" to be a valid value we can safely use and trust. If we do not have an instance of Age, then it must not have been a valid value.
Conclusion
At Benevity, some of our teams are embracing these patterns to build stronger systems. By encapsulating validation logic into strong types, and embracing Domain Driven Development our teams can work faster, with clear communication, and more confidently with the values we interact with.
In the next articles we will look at a different approach to handling errors rather than by throwing exceptions, and how we can achieve composable validation from these smaller ValueObjects.