Over the past few years, Node.js has been a popular choice to develop modern web applications. Not only because you can write your backend using the familiarity of JavaScript language or enhance it with TypeScript, but also because it allows you to use event loop. Even though JavaScript execution is single-threaded, the event loop allows performing I/O operations in a non-blocking fashion, allowing us to create asynchronous applications.
This all sounds great, but what if we want more? We’re still not using all the advantages of multi-core, next-generation processors, and we still need to solve the problem of requiring more resources on a high number of concurrent connections. Processing massive amounts of data can be another bottleneck, as Node.js does not provide any flow control mechanism. Exploring other options led us to Spring WebFlux, a reactive framework that can leverage functional Java's benefits, delivering high-performance applications with fewer resources.
In this tutorial, we’ll look at the fundamentals of functional programming in Java, which will allow us to use Spring WebFlux to write web applications. We’ll also use a functional router as a teaser of what you can accomplish with functional Java and WebFlux, which will be the topic of a follow-up article.
Spring WebFlux Intro
Spring WebFlux is the reactive stack of the Spring Framework; it can be compared to Spring MVC in the blocking world. It’s possible to use it with Spring Boot as well, and it provides many reactive implementations of other Spring components that are crucial to the development of applications, like Spring Security, Spring Data, and Spring Cloud Streams.
Another highlight of WebFlux is that it has Project Reactor as its foundation, a fully non-blocking fourth-generation reactive library with back-pressure support included for flow control. Thanks to this, WebFlux can run over the robust Netty framework, an asynchronous event-driven network application framework that is so lightweight that it can be shipped with your application’s JAR, meaning you won’t need Java Web/Application Servers (like WildFly, Apache Tomcat, GlassFish, etc.) anymore.
Working with WebFlux means working with reactive Java, and reactive Java also means functional Java. We need to leave behind the traditional imperative programming paradigm that has strongly characterized Java for many years and embrace the modern functional programming (FP) paradigm. First, let’s go through the functional programming principles and learn how to apply them to our Java code.
Functional Programming Principles in Java
1. Pure Functions
Purity is one of the primary fundamentals of functional programming. It means we should not have any memory or I/O side effects. Simply put, we should always get the same result from a function for the same input.
public int sum(final int a, final int b) {
return a + b;
}
Nothing in this function has side-effects. If invoked as sum(3, 2)
, the result will always be 5
. We should never run other operations or cause changes that are not expected from the behavior of the function:
public int sum(final int a, final int b) {
return new Random().nextInt() + a + b;
}
public int sum(final int a, final int b) {
this.launchMissiles();
return a + b;
}
Both examples above add side effects to the sum(..)
function. In the first, invoking sum(3, 2)
will always give different results. In the second, an operation will run within the function even if the output is constant for the same input.
2. Immutability
Simply put, immutability prevents unexpected side effects from spreading through our program. This means that once a state is created, it should never be changed. Instead, we create a new state with the required updates. So instead of doing something like this:
var greeting = "Hello";
greeting += " world";
System.out.println(greeting); // Hello world
We should do something like this:
final var prefix = "Hello";
final var greeting = prefix.concat(" world");
System.out.println(prefix); // Hello
System.out.println(greeting); // Hello world
Notice the prefix
variable hasn't changed at all. Instead, we've used the concat(..)
method that returns a new String
instance and assigns the result to the greeting
variable. Something else to note is the final
modifier. This prevents the variable from being changed once assigned. The final
modifier can be compared to JavaScript’s const
; we should use it on local variables, parameters, fields, etc., to avoid mutation in our code.
This is easy to achieve on primitives, but what about Java objects? The use of setters
is obviously out of the picture since those methods will effectively mutate the instance's state.
Using final
in the declaration of fields will prevent the creation of setters at all (which is a good thing). Java has no built-in feature that allows us to change an object immutably, but we can overcome this using some nice design patterns and extra help from Project Lombok to reduce the boilerplate.
Records and Withers
To help with object creation and modification, many developers prefer to use the builder pattern. However, we should not forget that a builder is still a mutable pattern, not to mention that even with Lombok, there’s a lot of boilerplate and extra code. If you are using Java 17+, a much better option is to use the brand new records, a special kind of class that lets us create immutable classes with significantly reduced boilerplate. To update a record, we can use Lombok’s @With annotation, which will create “wither methods” for us. The best way to describe withers is to consider them immutable setters. Instead of mutating the field in question, they create a new instance every time, but with the updated field. We call them "withers" because instead of prefixing the methods with "set" we use the "with" word. E.g. setName(..)
becomes withName(..)
.
If you can’t use Java 17+ yet, you can achieve almost the same as records with plain old Java classes and Lombok’s @Value annotation.
There's a simple condition we need to ensure if we want to use "withers": a constructor with all the arguments is always required. The reason is simple. If we don't have this constructor, there's no possible way of creating a new instance for each field we need to create a "wither" for. Luckily, records will always create an “allArgs” constructor for us.
import lombok.With;
@With
public record Person(
String name,
@Nullable LocalDate birthdate
) {
public static Person empty() {
return new Person(“”, null);
}
}
And to see a simple usage example:
final var timmy = Person.empty()
.withName(“Timmy”)
.withBirthdate(LocalDate.parse(“2020-03-15”));
final var littleTimmy = timmy.withName(“Little Timmy”);
timmy.getName() // Timmy
littleTimmy.getName() // Little Timmy
timmy.getBirthdate().equals(littleTimmy.getBirthdate()) // true
Simple and elegant. Another recommendation is always to add an .empty()
static factory method to create an empty or default instance of your class, which you can later modify using the withers. This practice has a few advantages over classic constructors:
Java does not offer “named arguments” like in other languages, so big constructors can be unsafe, deceiving, and hard to read. An .empty()
method will force us to use withers to solve this problem.
You can create more static factory methods if you need the required information to create the instance. These methods have more meaningful names than constructors with different numbers of arguments.
Your classes will align more with the new Java API, like Optional
, Stream
, DateTime
, and all the Collection
implementations. This will make your code more idiomatic as well.
3. Referential Transparency
This is more of a conceptual principle. A function is referentially transparent if it consistently returns the same result for the same data input. So to put it simply:
Pure Functions
+
Immutability=
Referential Transparency
4. First-class and Higher-order Functions
Having first-class functions means treating a function as a first-class citizen, supporting all the operations available to other entities (being passed as an argument, returned from a function, modified, and assigned to a variable). This means we have higher-order functions, which can receive functions as arguments and/or return a function as a result.
With this concept clear, the first thing that may jump to our mind could be: "But wait... Java does not have functions, only methods!" That was true until Java 8, where @FunctionalInterface
was introduced in conjunction with lambda expressions (which are syntactic sugar to implement interfaces anonymously).
The @FunctionalInterface
annotation
A functional interface is not so different from a classic Java interface. The difference is that we need to apply the @FunctionalInterface
annotation and that it should have exactly one abstract method:
@FunctionalInterface
public interface IntegerFunction {
public Integer apply(Integer num);
}
Simple enough, right? Before Java 8, we would have to implement this interface in a class, create a new instance, and call the method:
public class Add10Impl implements IntegerFunction {
@Override
public Integer apply(final Integer num) {
return num + 10;
}
}
final var add10 = new Add10Impl();
final var result = add10.apply(5); // 15
Lambda expressions
The above implementation is very tedious, and we would have to create more classes if we wanted to add more implementations. A workaround would be to assign the implementation to a variable declaring an anonymous implementation of the interface:
final IntegerFunction add10 = new IntegerFunction() {
@Override
public Integer apply(final Integer num) {
return num + 10;
}
}
This should work, but it gets even better! We can do it using a lambda expression:
(Type1 arg1, Type2 arg2, ...) -> { /* implementation */ }; // Complete syntax
(arg1, arg2) -> returnValue; // simplified syntax
arg -> returnValue; // simplified syntax with a single argument
This way we can implement some IntegerFunction
functions in a more compact and readable way:
final IntegerFunction addTen = (Integer x) -> { return x + 10 };
addTen.apply(5); // 15
final IntegerFunction subtractFive = x -> x - 5;
subtractFive.apply(12); // 7
Method references
We said Java used to have only methods, so what if we also want to use them as first-class citizens? We can, but we'll need a slightly different syntax. Instead of accessing the methods with the .
operator, we should refer to them using the ::
operator. This is known as method reference:
private void greeter(Supplier<String> supplier) {
final var name = supplier.get();
System.out.println("Hello, my name is " + name);
}
// johnDoe.getName() signature is `public String getName() { .. }`
// which fulfills the `Supplier` function type
greeter(johnDoe::getName); // Hello, my name is JohnDoe
// the Person constructor signature is `Person(String name, Date birthdate) { ... }`
// which fulfills the type of `BiFunction<String, Date, Person>` function type
final BiFunction<String, Date, Person> createPerson = Person::new;
createPerson.apply("Peter", new Date()); // new Person object
Generalizing the types
Functions are usually a generalization of something, so it makes sense to think that we wouldn’t know the types of the functional interface until we really implement it. Java allows us to use generic types to work our way into function generalization. As an example, we can write a functional interface that will map one value to another:
@FunctionalInterface
public interface MapFunction<T, R> {
public R apply(T t);
}
We don't know which type will be mapped to which or how, so in MapFunction<T, R>
we represent the input type as T
and the return type as R
. Then we can implement different mappers for different types:
final MapFunction<Integer, String> mapToCurrency = value -> "$" + value;
final var bill = mapToCurrency.apply(5000); // "$5000"
final MapFunction<String, String[]> mapToWords = phrase -> phrase.split(" ");
final var words = mapToWords.apply("Hi! My name is John"); // ["Hi!", "My", "name", "is", "John"]
Generalization is very common in functional programming; therefore, Java has given us some built-in generic functional interfaces:
Predicate<T> // (T t) -> boolean;
Consumer<T> // (T t) -> void;
Supplier<T> // () -> T;
Function<T, R> // (T t) -> R;
UnaryOperator<T> // (T t) -> T;
BiFunction<T, U, R> // (T t, U u) -> R;
BinaryOperator<T> // (T t1, T t2) -> T;
// Some more variations...
// And the implementation of the identity function
Function.identity(); // (T t) -> t;
Stop procedural patterns, embrace Stream<T>
After all this information you might be thinking "Ok but, how do I avoid mutation when working with collections?" And how would we not question that? Working with arrays and collections (List<T>
, Map<T>
, etc.) will inevitably result in the use of imperative loops with state mutation. This is because arrays and collections are inherently mutable, and they allow that with the help of built-in methods like .add(..)
, .remove(..)
, .set(..)
, .put(..)
, .replace(..)
, etc. This is what we call procedural design patterns. If we want to do functional Java, we'll need to avoid them completely:
/**
* IMPORTANT: The following are examples of procedural patterns
* that we should avoid!
*/
final int[] intArray = {1, 2, 3, 4, 5};
for (int i = 0, i < intArray.length; i++) {
// This is possible even though intArray is final :(
intArray[i] = intArray[i] * 2;
}
final List<Integer> intList = new ArrayList({-1, 5, -4, 6, 2});
final List<Integer> intList = new ArrayList<>();
intList.add(-1);
intList.add(3);
intList.add(-4);
intList.add(6);
intList.add(2);
// Oh no! Not this pattern :(
for (int i = intList.size() - 1; i >= 0; i--) {
if (intList.get(i) > 0) {
intList.remove(i);
}
}
So, we said that working with arrays and collections will inevitably result in mutation, but that's not entirely true! Arrays can always be mutated; we'll see how we can overcome that later, but some time ago, Java introduced the concept of immutable collections, which we can get by simply using the built-in static factory methods (List.of(..)
, Map.of(..)
, etc.). Trying to modify immutable collections will result in a UnsupportedOperationException
thrown at runtime, so how can we transform and process these collections?
This is where the Stream<T>
API comes into the picture. We can transform arrays and collections into Stream<T>
, use its API to transform and process the data, and then collect it back into an array or collection. We do this with the functionally popular operators of .map(..)
, .filter(..)
, .reduce(..)
, etc. Diving deep into Java Streams deserves its article, so let's see some examples of how to use them and keep doing functional programming on Java:
final var intArray = new int[] {1, 2, 3, 4, 5};
final var timesTwo = Arrays.stream(intArray)
.map(num -> num * 2)
.toArray();
// {2, 4, 6, 8, 10}
final var onlyPositives = List.of(-1, 3, -4, 6, 2)
.stream()
.filter(num -> num > 0)
.toList();
// [3, 6, 2]
final var addition = Stream.of(1, 2, 3, 4, 5)
.reduce((acc, num) -> acc + num)
.get();
// 15
The Java Stream API provides lots of methods that will fit our needs. For now, we know that we can use them to manipulate data of immutable arrays and collections in a functional fashion.
Handling nullability with Optional<T>
Now that we have the basics for Java functional programming, let's dive into how to better handle null references in a functional way. There are multiple ways to handle nullability in Java. One of the best is leveraging a couple of tools to ensure everything is “non-null by default”, but that’s a topic for another article. For now, we’re going to focus only on what Java provides out-of-the-box.
Null references are usually the source of many problems as they denote a value’s absence. As Java developers, we may all have experienced the nightmare of java.lang.NullPointerException
when we try to reference a method/property from a null instance. To handle this, we usually add if-statements (sometimes deeply nested) to check for null values. However, since the release of Java 8, a better and more functional solution was introduced with java.util.Optional
.
Optional<T>
is a container object that may or may not contain a non-null value of the parameterized type T
. If the value is not present, then we'll have an empty Optional
instance. The functional approach comes when we start handling and transforming the contained value. The Optional
class has many methods that take functions in their arguments (i.e. map
, flatMap
, filter
, etc.), which we can use to handle the value in many ways.
final Optional<String> foo = Optional.of(“foo”);
final Optional<String> foo = Optional.ofNullable(arg1); // arg1 may or may not be null
// Given arg1 has a property `bar` which has a property `baz`
final Integer baz = Optional.ofNullable(arg1)
.map(arg1 -> arg1.getBar())
.map(bar -> bar.getBaz())
.orElse(other);
// Check if value is present inside the container
if (foo.isPresent() && !foo.isEmpty()) {
System.out.println(foo); // Optional<String>[foo]
System.out.println(foo.get()); // We can unwrap the value and get “foo”
}
final List<Integer> nums = List.of(1, 5, -3, 2, 7, -1, -6, 9);
// Works with collections
final List<Integer> positives = Optional.of(nums)
.filter(n -> n >= 0)
.orElseGet(List::of); // alternative, if the output of the filter is "empty"
class Person {
private Optional<String> name;
// constructors
// getter...
}
// Works with complex objects
final Optional<String> optionalName = Optional.ofNullable(person)
.map(person -> person.getName()); // person.getName() will return an Optional<String>
// We can flatten the transformation if the returned value is an Optional
final String name = Optional.ofNullable(person)
.flatMap(person -> person.getName());
It's a good idea to get used to Optional<T>
as it will help us to better understand the reactive containers Mono<T>
and Flux<T>
, which WebFlux is based on. We'll go deeper into these containers in the next article, but for now, let's think of them as containers that will help us manage data flow streams in reactive applications.
A functional router teaser with WebFlux
We've seen a lot of new concepts in this post, but don't worry; they will all help create a base of knowledge to help work with functional/reactive applications. To wrap up, let's go through a small example of what a WebFlux router will look like. This helps us understand how useful it is to make Java functional and how robust and popular frameworks are embracing their patterns.
@Configuration
public class Router {
@Bean
public RouterFunction<ServerResponse> apiRouter(final PersonHandler personHandler) {
return route()
.path("/api", () ->
route(
.GET("/say-hello", request ->
Mono.justOrEmpty(request.queryParam("name"))
.map("Hello %s!"::formatted)
.switchIfEmpty(Mono.just("Hello world!"))
.flatMap(ok()::bodyValue)
)
.path("/person", () ->
route()
.GET("", personHandler::getAll)
.GET("/{id}", personHandler::getOne)
.POST("", personHandler::save)
.PUT("", personHandler::update)
.DELETE("/{id}", personHandler::delete)
.build()
)
.build()
)
)
.build();
}
}
Notice how this functional router uses lambda expressions and method references to add handlers for each route. We can also see the use of .map
and .flatMap
, which are common ways to manipulate data when working with functional programming and Reactive Streams. It's also worth mentioning that these functions are pure, and the router is free of side effects.
Conclusion
To conclude, we have learned that functional programming is the foundation of reactivity, and we have seen how to apply some of these principles to the Java programming language. Spring WebFlux can be used to build high-performance, responsive, and resilient applications. We presented a teaser of a functional router with WebFlux to show this, but in the following article, we’ll delve deeper into the complex subjects of Reactive Streams and WebFlux and guide you on how to build reliable, robust, and top-quality applications that will take your software to the next level. Stay tuned for more exciting articles!