In my previous post — "How do we make complex software less costly?" — I drew the distinction between essential and accidental complexity. The clue is in the name — you can't avoid what is essential. Build a good understanding of your core problem, otherwise, you risk introducing more unnecessary complexity.
This time, let's talk about a specific functionality — Java Optional. I'll use an example project to show you how Java 8 Optional can impact the end complexity. And it can do so in a positive or negative way as Optional as a tool can both simplify and complicate your code. In the long run, the outcome can greatly influence your project. Let's see how it works.
You can either use Optional without noticing...
I'll use car fleet management as an example. Imagine you need to handle adding bicycle racks to cars. The system is going to retrieve a car from the database and extend it with bike rack data.
Now, try to read the following Java method from top to bottom. My bet is, even with zero knowledge of Java, you will be able to make sense of some of the lines:
BicycleRack updateRack(String carUuid, BicycleRackData rackData) { return carRepository.findByUuid(carUuid) .map(car -> { if (car.hasNoRack()) { return newRack(rackData, car); } return updatedRack(rackData, car); }) .orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND)); }
So there is a repository of cars, which you use to find a car by its unique identifier. The moment you get hold of the car, you check if it already has a rack. If not, you define a new rack for that car. Otherwise, you update the existing rack. The last line throws an error in case of no car found.
...or struggle to see through it!
Now, try to read this:
BicycleRack updateRack(String carUuid, rackData rackData) { Optional<BicycleRack> bicycleRack; Optional<Car> car = carRepository.findByUuid(carUuid); BicycleRack rack; if (car.isPresent()) { bicycleRack = Optional.ofNullable(car.get().getRack()); if (bicycleRack.isPresent()) { rack = bicycleRack.get(); copier.map(rackData, rack); } else { rack = copier.map(rackData, BicycleRack.class); } rack.setCar(car.get()); } else { throw new ResponseStatusException(HttpStatus.NOT_FOUND); } return rack; }
I read and write Java code for a living, but seeing this makes me feel like searching for the nearest exit. Every line does things that I couldn't understand without reading all the rest. No matter how long I stare at my screen, I can't be sure if the code works.
As you have already guessed, when you run it, the second piece works exactly like the first one. The main difference is that it makes poor use of the Optional construct in Java.
In the following sections, I'll point out what went wrong.
Origins of the Optional class
Java 8 drew inspiration from functional languages like Haskell or Scala. One of such innovations was a new class Optional<T>. As a return type, it is a hint for the caller that the value may be empty. This is especially useful for transport across contexts, like classes or methods.
In our case, the most obvious place to use it is for database query results. Calling carRepository.findByUuid(carUuid) means trying to find data by ID. If data is not present, you get an empty Optional.
Another great thing about Optional is that it is a monadic type. It implements a flatMap() bind operation as well as unit operations like of() and empty(). Because of that, it can give better structure to your code. You can chain transformations that you apply to values. You can also handle the side-effect — no value present in this case — in an easy-to-read way.
The default value for empty Optional
All that said, Optional didn't do much good in the second version of updateRack() listed above.
Let's go through the way that code uses Optional, in hope that the designers of Optional never see it! They would be very disappointed.
BicycleRack updateRack2(String carUuid, rackData updateDto) { Optional<BicycleRack> bicycleRack; Optional<Car> car = carRepository.findByUuid(carUuid); // (1) BicycleRack rack; if (car.isPresent()) { // (2) bicycleRack = Optional.ofNullable(car.get().getRack()); // (3) ( ) Optional<BicycleRack> existingRack = car.map(Car::getRack); // (4) } // public<U> Optional<U> map(Function<? super T, ? extends U> mapper) { // Objects.requireNonNull(mapper); // if (!isPresent()) // return empty(); // else { // return Optional.ofNullable(mapper.apply(value)); // (5) // } // }
After the attempt to fetch an existing car in line (1), and the check if it is present in line (2), comes line (3). The author of this code has adopted a defensive strategy, which is good. The danger of NullPointerExceptions in Java is real. Still, Optional is even more helpful than the author assumed.
If you look at the commented out part at the end, you will see the implementation of map() in the original Optional class. The map() method produces an empty Optional if no value is present. It means that there is no need to do the check by ourselves in line (2). Next, in line (5) the map() method wraps the result of the mapping in the same way line (3) does. Which makes lines (2) and (3) a waste of time. They are easy to replace with a call to map(), as you see in line (4).
At this point, it seems reasonable to replace this version of the code:
BicycleRack updateRack(String carUuid, rackData rackData) { Optional<BicycleRack> bicycleRack; Optional<Car> car = carRepository.findByUuid(carUuid); BicycleRack rack; if (car.isPresent()) { bicycleRack = Optional.ofNullable(car.get().getRack()); if (bicycleRack.isPresent()) { rack = bicycleRack.get(); copier.map(rackData, rack); } else { rack = copier.map(rackData, BicycleRack.class); } rack.setCar(car.get()); } else { throw new ResponseStatusException(HttpStatus.NOT_FOUND); } return rack; }
By removing all the if statements and additional Optional instances:
BicycleRack updateRack(String carUuid, rackData dto) { return carRepository.findByUuid(carUuid) .map(Car::getRack) .map(rack -> { copier.map(dto, rack); return rack; }) .orElseGet(() -> copier.map(dto, BicycleRack.class)); // missing setCar() and orElseThrow }
Now it looks so much simpler!
Actually, there are two details that got lost on the way. We still need to set a reference to the car in the rack and handle the case where no car with a given ID exists.
Null check free zone
If you want to keep your code easy to read, it is good to extract some parts to utility methods. There are two paths of execution that the rack update can take. There is either a new rack to define from scratch or an update to run on an existing rack. This gives us two natural candidates for utility methods:
BicycleRack newRack(rackData updateDto, Car car) { BicycleRack rack = copier.map(updateDto, BicycleRack.class); rack.setCar(car); return rack; } BicycleRack updateRack(rackData updateDto, Car car) { BicycleRack rack = car.getRack(); copier.map(updateDto, rack); rack.setCar(car); return rack; }
And when we call them inside our main method, this is our final result:
BicycleRack updateRack(String carUuid, rackData dto) { return carRepository.findByUuid(carUuid) .map(car -> { if (car.hasNoRack()) { return newRack(dto, car); } return updateRack(dto, car); }) .orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND)); }
Notice how we are using Optional API without creating any more Optional instances. We are getting an instance from the database search, and then working with it to accommodate all logic needed.
The last line handles the side-effect caused by no car found. If you use the Spring Framework, you should be able to skip that part. Returning an empty Optional from your controller method can produce HTTP 404. No exception is ever thrown.
Use Optional with caution
We have seen the impact a single concept in Java can have on accidental complexity. As the saying goes, the only sure thing in software is change. Members of your team would need to read the overcomplicated version many times in the future. They would struggle to understand it, and produce bugs while trying to change it.
Low-level accidental complexity often begins with pressure to deliver changes fast. However, in the long run, it pays off to take the time to make your code better. Explore the APIs you are using and take every opportunity to discuss code with your team. By that, you will make future changes easier to implement. Everyone will appreciate it, especially your stakeholders!