There are lots of guides that teach the basic about using CompletableFutures in java (for example http://www.nurkiewicz.com/2013/05/java-8-definitive-guide-to.html), but I have not seen much that describes the best ways to actually use them in real code. This is my attempt to share some of the lessons I’ve learned over the last year.
Keep lambdas short and simple
This is probably the most important lesson that I’ve learned. Using the
thenCompose() family of methods of CompletableFuture makes it tempting to use of lambdas liberally - and you should, lambdas are extremely useful. But you must have the discipline to keep the code featured in the lambda short and simple. As a rule of thumb I’d say no more than 5 lines. It is really easy add just one more line, or one more condition to the lambda, but that violates good software design. Instead have methods that do just one thing. So instead of writing the code to extract values from a JSON response in a lambda, write an
extractValues(String json) method instead. The code becomes easier to read and easier to test. As a corollary each CompletionStage should only complete a single task. Keep your code modular.
Use a container to pass state between CompletionStages
Sometimes you need a value calculated in one CompletionStage in multiple stages down the line. For example, say you have a pipeline that uploads a file only if it isn’t already stored in a remote system (like dropbox).
- The pipeline starts with a username and a target file.
- It looks up an OAuth token for the 3rd party service.
- It uses the OAuth token to read a list of files that are stored.
- It checks the list of files to see if the service contains the target file.
- If not, it uses the OAuth token to upload or update the file.
In this example you need the OAuth token in two distinct stages, and looking it up twice would be a waste of valuable database resources and would add latency. Because of variable scope rules in java a straight forward solution isn’t available. One possible solution is to use nested CompletableFutures in the lambdas.
This works, but it can become difficult to follow the flow of the code, especially if you need multiple nested layers. The solution I favor is to create a container for all of the pieces of state that needs to pass between multiple lambdas.
Make the setter methods return the data container instance to allow for cleaner code in the CompletableFuture pipeline. This relies on the shorthand version of a lambda that only has a single statement and returns the result of that statement.
thenApply()s take the value calculated in that stage and adds it to the container before passing the container on. This code is easier to read and understand. I particularly like that it only has a single return statement. That being said, I don’t love this pattern, I just haven’t found a better one yet. If you have another pattern that solves the problem and you like it more, please leave a comment and share. I’ll happily update this post for better solutions.
Separate asynchronous code from synchronous code
Sometimes what you want to do doesn’t fit well with CompletableFutures. Perhaps you need to use JDBC to lookup a value from a database. (Last I checked JDBC doesn’t have an asynchronous API.) The first solution I tried was to use the
supplyAsync() method on CompletableFuture with the code written in a lambda.
This works pretty well, but the code is better with multiple methods - one to do the DB lookup and one to create the CompletableFuture. It makes both methods easier to read and easier to test. Additionally I like that the code is written with checked exceptions instead of unchecked ones. You just have to wrap the checked exceptions where you actually create the CompletableFuture which the compiler enforces.
I like these patterns because they help me produce code that is easy to read and easy to test. Through years of experience I’ve found that bugs like to hide in complicated code, therefore I try to minimize complicated code (sometimes at the cost of writing a few more lines of code). I try to make sure the cyclomatic complexity of my methods is small enough that I can write test cases that cover most of the code. I try to make sure that methods will fit on a single screen without scrolling so that I can see everything at once. I try to adopt and share useful patterns to make all the code easy to understand and modify. Writing code this way can mean a higher cost at the start of a project but it lowers the maintenance cost later so it is worthwhile.
If you disagree with me please write a comment and let me know why. If I missed a useful pattern please let me know about it.