How Fluent Interfaces Can Lead to More Meaningful Code

 · 9 min
贝莉儿 DANIST on Unsplash

Martin Fowler, who coined the term Fluent Interfaces 15 years ago, famously wrote in his book Refactoring: Improving the Design of Existing Code:

“Any fool can write code that a computer can understand. Good programmers write code that humans can understand.”

Software development is about communication.

The eventual recipient might be a compiler or interpreter, running our code as a series of ones and zeros. Computers don’t care about the readability of source code, but the other participants in the communication do: humans.

It’s about one developer communicating with another one. Or with ourselves in the future, when our code appears to us like it was written by someone else.

I’ve written about the importance of the visual representation of code before, this article will highlight the concept of fluent interfaces and how we can incorporate it into our codebases for instance creation. But it will also show the downsides of using this particular API style.

All code examples will be in Java, or reference Java frameworks. But the general principles of fluent interfaces apply to any other language, too.


Fluent Interfaces

As with many other design patterns, the core concept can be summarized in a short sentence:

A fluent interface is designed to mimic a Domain-Specific-Language (DSL) by relying heavily on method-chaining.

In object-oriented programming, method-chaining is a way of invoking multiple method-calls successively. This allows us to write our code in a fluent, more straightforward way, and reduce visual noise.

To achieve the ability to actually use method-chaining, methods can’t return void or another arbitrary value. They must return the most logical value for the given context.

There are three ways in which we can have more fluent code.

The mutable way

Each setter or related method of an instance mutates the instance itself and returns this.

This way of fluency isn’t appropriate for instance creation, at least in my opinion. We gain method-chaining, but that’s all. It should be reserved for non-POJO types, e.g., fluent interfaces for workflows.

The immutable way

Instead of returning the instance itself, a new instance representing the mutated state is returned. A prime example of this behavior is the java.time API, or java.math.BigDecimal:

java
// CORRECT
BigDecimal basePrice = new BigDecimal("9.99");
BigDecimal withTaxes = basePrice.multiply(new BigDecimal("1.19"));

// INVALID
BigDecimal basePrice = new BigDecimal("9.99");
basePrice.multiply(new BigDecimal("1.19")); // won't modify basePrice

With intermediary builder

The type itself doesn’t have to be fluent. We could use an additional fluent auxiliary type for instance creation, e.g., a fluent builder.

In Java, we can use builders to get around the lack of named parameters.


How to Create a Fluent Builder

We start with a type describing a developer:

java
public class Developer {

    private String email;
    private String name;
    private List<String> languages;

    public String getEmail() {
        return this.email;
    }

    public void setEmail(String email) {
        this.email = email;
    }

    public String getName() {
        return this.name;
    }

    public void setName(String name) {
        this.name = name;
    }

    public List<String> getLanguages() {
        return this.languages;
    }

    public void setLanguages(List<String> languages) {
        this.languages = languages;
    }
}

A simple POJO, just storing some data, without any extra functionality.

Creating a simple builder

To improve instance creation, we need an additional builder type:

java
public class DeveloperBuilder {

    private String email;
    private String name;

    private final List<String> languages = new ArrayList<>();

    public DeveloperBuilder email(String email) {
        if (email == null || email.length() == 0) {
            throw new IllegalArgumentException("email musn't be null/empty");
        }

        this.email = email;

        return this;
    }

    public DeveloperBuilder name(String name) {
        this.name = name;
        return this;
    }
    
    public DeveloperBuilder language(String lang) {
        if (lang == null || lang.length() == 0) {
            throw new IllegalArgumentException("lang musn't be null/empty");
        }

        if (this.languages.contains(lang)) {
            return this;
        }

        this.languages.add(lang);

        return this;
    }
    
    public Developer build() {
        if (email == null || email.length() == 0) {
            throw new IllegalStateException("email is a required field");
        }

        if (this.language.isEmpty()) {
            throw new IllegalStateException("at least one language must be added");
        }

        Developer developer = new Developer();
        developer.setEmail(this.email);
        developer.setName(this.name);
        developer.setLanguages(this.languages);

        return developer;
    }
}

By using the builder pattern, we still have our simple POJO type. But now instance creation can also have validation during preparing the builder and at creating the actual instance.

Using the builder

java
Developer validDev =
    new DeveloperBuilder().email("ben@example.com")
                          .name("Ben Weidig")
                          .language("Java")
                          .language("Swift")
                          .language("Golang")
                          .build();

Developer invalidDev1 =
    new DeveloperBuilder().name("Ben Weidig")
                          .language("Java")
                          .language("Swift")
                          .language("Golang")
                          // throws IllegalStateException
                          .build(); 

Developer invalidDev2 =
    new DeveloperBuilder().email("ben@example.com")
                          .name("Ben Weidig")
                          // throws IllegalArgumentException
                          .language(null)
                          .language("Swift")
                          .language("Golang")
                          .build();

The fluent builder separates the code for construction and representation.

We gained a more understandable way of creating instances, with additional control, like validation, during the construction process. Especially complex types can be improved with builders.

But we can improve our builder, and also the corresponding type, even more.

Improving the builder with immutability

An often-used design is to make the builder a nested class of its corresponding type. This also means giving up a pure POJO, but gaining an immutable type instead, which is a great bargain in my opinion:

java
public class Developer {

    private final String email;
    private final String name;
    private final List<String> languages;

    // private constructor, so we are forced to use the builder
    private Developer(String email,
                      String name,
                      List<String> languages) {
        this.email = email;
        this.name = name;
        // make languages collection immutable, too
        this.languages = Collections.unmodifiableList(languages);
    }

    public String getEmail() {
        return this.email;
    }

    public String getName() {
        return this.name;
    }

    public List<String> getLanguages() {
        return this.languages;
    }

    public static Builder builder() {
        return new Builder();
    }

    public static class Builder {

        private String email;
        private String name;
        private final List<String> languages = new ArrayList<>();

        // builder needs to be created with static method on actual type
        private Builder() { }

        public Builder email(String email) {
            if (email == null || email.length() == 0) {
                throw new IllegalArgumentException("email musn't be null/empty");
            }

            this.email = email;

            return this;
        }

        public Builder name(String name) {
            this.name = name;
            return this;
        }

        public Builder language(String lang) {
            if (lang == null || lang.length() == 0) {
                throw new IllegalArgumentException("lang musn't be null/empty");
            }

            if (this.languages.contains(lang)) {
                return this;
            }

            this.languages.add(lang);
            return this;
        }

        public Developer build() {
            if (this.email == null || this.email.length() == 0) {
                throw new IllegalStateException("email is a required field");
            }

            if (this.languages.isEmpty()) {
                throw new IllegalStateException("at least one language must be added");
            }

            return new Developer(this.email,
                                 this.name,
                                 this.languages);
        }
    }
}

The Developer type is now wholly immutable, and can only be created by using the builder.

java
Developer valid = Developer.builder()
                           .email("ben@example.com")
                           .name("Ben Weidig")
                           .language("Java")
                           .language("Swift")
                           .language("Golang")
                           .build();

Immutable builder generation

Let’s be honest… that’s still a lot of additional code. And we would need to write it for every POJO-like type.

Instead of doing it ourselves, we can use frameworks like Immutables:

java
@Value.Immutable
public interface Developer {

    String getEmail();

    @Nullable
    String getName();
  
    @Value.Default
    default List<String> getLanguages() {
        return Collections.emptyList();
    }
}

Thanks to annotation processing, our builders are generated for use, including validation, more builder methods, easy copying, and a lot more features than we could ever do ourselves:

java
Developer dev = ImmutableDeveloper.builder()
                                  .email("ben@example.com")
                                  .name("Ben Weidig")
                                  .addLanguages("Java")
                                  .addLanguages("Swift", "Golang")
                                  .build();

Fluent Interfaces for Workflows

Even though this article is mainly about fluent builders, it’s not the only way to use fluent interfaces. They are widely adopted for any kind of multi-step workflow.

An excellent example of fluent API design is the Java Streams API. We can reduce a lot of verbosity by method-chaining Stream-related calls together, instead of using the traditional means of iterating over a collection:

java
// "TRADITIONAL" WAY OF ITERATING

List<Book> books = ...;
List<String> result = new ArrayList<>();

for (Book book : Books) {
    if (book.getPublishYear() != 2020) {
        continue;
    }

    if (book.getGenre() != Genre.SCIENCE_FICTION) {
        continue;
    }

    Author author = book.getAuthor();
    String sortableName =
        String.format("%s, %s",
                      author.getLastName(),
                      author.getFirstName());

    result.add(sortableName);

    if (result.size() == 5) {
        break
    }
}

Collections.sort(result);

A lot of code for getting the names of the first five science fiction authors of 2020 in our collection.

Let’s make this call fluent with streams:

java
// FLUENT WITH STREAM

List<Book> books = ...;

List<String> result =
    books.stream()
         .filter(book -> book.getPublishYear() == 2020)
         .filter(book -> book.getGenre() ==  Genre.SCIENCE_FICTION)
         .limit(5)
         .map(Book::getAuthor)
         .map(author -> String.format("%s, %s",
                                      author.getLastName(),
                                      author.getFirstName()))
         .sorted()
         .collect(Collectors.toList());

If we include the formatting and brackets, we managed to reduce 20 lines of code down to ~10 lines.

The reduced clutter is great. But in my opinion, improved comprehensibility is way more important. The concise names of the Stream operations convey their intent clearly.


Conclusion

Fluent interfaces are great for consumption, but not for creation.

Creating a more DSL-like API helps developers to consume it with less verbose, more comprehensible code.

We get more control over how we create an object, even splitting it into multiple steps. Validation can be integrated at every step. And it helps to separate the code for creation from the actual representation.

But creating an enjoyable, fluent API isn’t an easy task. We need additional auxiliary types, which means a lot of extra, and often repeated, code.

Another downside is creating the possibility for more runtime-errors. If our type needs all its required parameters in the constructor, we would directly see what’s required. With a builder, we might miss them and won’t realize until runtime.

Debugging can also be harder if our IDE doesn’t support breakpoints at different points in the chain. This can be often overcome by breaking the chain into multiple lines, which coincidentally improves readability, too.

At my company, we started using a fluent builder last year, thanks to the Immutables framework. We started by using it only for new types, but also replaced older types over time, if appropriate.

Sometimes it wasn’t easy to integrate immutable types into the existing code. But it gave us a chance to reevaluate our API design and improve on it.


A Functional Approach to Java Cover Image
Interested in using functional concepts and techniques in your Java code?
Check out my book!
Available in English, Polish, Korean, and soon, Chinese.

Resources