Null Safety In Java
RyTheTurtle
Posted on February 11, 2024
Using Java’s Optional class provides null safe access to nested object hierarchies and chained method calls. Using Optional.map()
can safely wrap chained method calls without excessive if/else checks and minimize branching. This keeps code easier to read, minimizes cyclomatic complexity, and reduces the amount of unit test maintenance needed to maintain full test coverage.
Introduced in Java 1.8, the Optional class gives a null safe standard library class for Java developers to express that a value may or may not be present. Null safety is an important characteristic of code, so much so that null safety is a first class feature of many newer programming languages. Java, with it’s dedication to backwards compatibility, opted to introduce a standard library container Optional to deal with null safety while maintaining backward compatibility.
While Optional and Java 1.8 aren’t exactly new at the time of writing this article, I wanted to explore and showcase specifically how working with Objects and APIs that you may not be able to refactor to directly return Optional can still leverage Optional to safely access nested members. More specifically, Optional lets us safely access nested members of objects without exponentially blowing up cyclomatic complexity (basically the number of possible branches your code could execute). Minimizing cyclomatic complexity makes code easier to read and, perhaps just as importantly, minimizes the amount of “noise” in our unit test suites.
Consider the following scenario: We have a User
object , with various fields for properties about the user. For some reasons (they were written before Java 1.8, the classes need non optional returning getters and setters for serialization, etc) , the classes that make up the User
cannot be edited. The project owners in this example insist on 100% line and branch coverage with unit tests which is enforced by static analyzers that run on all merge requests.
The task is to write a method in a utility class (the topic of utility classes and their merits is a topic for another time, so just go with it here…) that validates whether or not our User
’s zip code is local to one of our business’s physical stores.
First, here are the classes for the User and it’s associated information
User.java
//...
public class User {
private UserName userName;
private Address address;
private PhoneNumber phoneNumber;
// typical getters and setters ...
}
Address.java
//...
public class Address {
private StreetAddress street;
private StateAddress state;
// typical getters and setters ...
}
StateAddress.java
//...
public class StateAddress {
private String city;
private String state;
private PostalCode postalCode;
// typical getters and setters
}
PostalCode.java
public class PostalCode {
private String zip;
private String zipPlus4;
// typical getters and setters
}
The task is to write a utility function that validates the user’s zip code matches a zip code from a list of our business’ physical locations. This is the implementation of that logic (using hardcoded set of store zip codes for demonstration purposes).
// ...
public class UserUtil {
private static final Set<String> storeZipCodes = Stream.of("80208").collect(Collectors.toCollection(HashSet::new));
private UserUtil(){}
public static boolean isNearPhysicalLocation(User user) {
String mainZip = user.getAddress()
.getState()
.getPostalCode()
.getZip();
return storeZipCodes.contains(mainZip);
}
}
UserUtilTest.java
//...
class UserUtilTest {
@Test void isUserNearPhysicalLocationReturnsTrueWhenSameZip() {
User user = buildTestUserWithFullAddress();
assertTrue(UserUtil.isNearPhysicalLocation(user), " should return 'true'");
}
//...
}
This doesn’t seem too bad so far. This utility method has a cyclomatic complexity of 1 (meaning no branching) in isNearPhysicalLocation, and has 100% line coverage from unit tests! Seems like it’s good to pass all the static analyzers for code review and get approved by the project owners, right?
Or…maybe not. This method is not null safe because if the user is null, or the user’s state field is null, or the user’s state’s postalCode field is null, then this method will throw a NullPointerException
.
UserUtilTest.java
//...
@Test void isUserNearPhysicalLocationReturnsFalseIfMissingPostalCode() {
User user = buildTestUserWithFullAddress();
user.getAddress().getState().setPostalCode(null);
// we get NullPointerException here!!
assertFalse(UserUtil.isNearPhysicalLocation(user), " should return 'false'");
}
So how can null safety be implemented? A common approach is to add some null checks in our method to short circuit along the way. Something like this would probably get a pass from a code review for null safety.
// UserUtil.java
// ...
public static boolean isNearPhysicalLocation(User user) {
if(Objects.isNull(user)){
return false;
}
Address userAddr = user.getAddress();
if(Objects.isNull(userAddr)){
return false;
}
StateAddress stateAddr = userAddr.getState();
if(Objects.isNull(stateAddr)){
return false;
}
PostalCode postalCode = stateAddr.getPostalCode();
if(Objects.isNull(postalCode)){
return false;
}
return storeZipCodes.contains(postalCode.getZip());
}
Each level of the User object is checked to makes sure we appropriately short circuit if there are some missing fields. This approach has a few problems:
It’s noisy. Simple
null
checks have more than doubled the size of the methodCyclomatic complexity. Using Codalyze to measure complexity, the cyclomatic complexity of this method increased from 1 to 5. Typical static analyzers will throw errors if the cyclomatic complexity reaches 10, and this method is halfway to that threshold just to safely access a field in an object that is a few levels deep.
Unit test coverage. The extra possible branches of code that can execute here has also added more scenarios that must be covered by unit tests. According to Jacoco, test coverage has dropped to 80% of lines and only 50% of branches. 4 additional unit tests are needed to get all of the branches of code covered for this method.
So adding null safety to this very simple method has exploded the amount of required unit tests, lines of code, and cyclomatic complexity. Time to refactor to achieve the same null
safety using Optional.
// UserUtill.java
// ...
public static boolean isNearPhysicalLocation(User user) {
Optional<String> zip = Optional.ofNullable(user)
.map(u -> u.getAddress())
.map(a -> a.getState())
.map(s -> s.getPostalCode())
.map(p -> p.getZip());
return storeZipCodes.contains(zip.orElse(""));
}
Optional.map
checks if the Optional has a value, and only runs the supplied mapping function if the value is present. If the Optional has no value, the map function returns an empty Optional. The orElse
method returns the value in the Optional if it’s present, or the supplied value if the Optional is empty.
This method now safely traverses a deep object structure without adding noise to the logic with null checks, maximizing readability and safety while minimizing cyclomatic complexity. As an added bonus, this approach gives us 100% line and branch coverage from the unit tests without needing to explicitly test for null checks at each level!
Using Optional wisely is a great way to tame cyclomatic complexity and exponential test suite expansion while still defensively programming against null
references. This is especially helpful if you have chained method calls, such as when traversing a deep object structure, where refactoring the method signatures to return Optional isn’t feasible.
Posted on February 11, 2024
Join Our Newsletter. No Spam, Only the good stuff.
Sign up to receive the latest update from our blog.