Rethinking Java Design Patterns: from OOP to FP (3)
After having rethought the Factory and the Visitor paterns in previous posts let’s look now at another creational one: the Builder.
The Builder
This design pattern belongs to the creational category, like the factory, but it solves a different problem. The factory hides which concrete type gets instantiated while the Builder assembles a single, complex object step by step, separating its construction from its representation. It is the classic answer to the telescoping-constructor problem: an object with many parameters, among which some required, most optional, whose constructor would otherwise explode into a combinatorial set of overloads.
Our Product records have only three required fields, so they don’t motivate a
builder. We therefore introduce an Order: a customer order that aggregates the
common products as line items and adds several optional attributes - a coupon
code, a gift-wrap flag and a free-text note. Whatever the style, the target is the
same immutable value:
public record Order(
String customer,
String currency,
List<Product> items,
Optional<String> coupon,
boolean giftWrapped,
Optional<String> note)
{
public Order
{
Objects.requireNonNull(customer, "Customer is null");
Objects.requireNonNull(currency, "Currency is null");
items = items == null ? List.of() : List.copyOf(items);
coupon = coupon == null ? Optional.empty() : coupon;
note = note == null ? Optional.empty() : note;
}
public BigDecimal subtotal() { ... }
}
The object-oriented approach
The figure below shows the class diagram of the object-oriented builder:

The classic Gang of Four Builder is a mutable accumulator. The required
arguments are captured up front, the optional ones are added through fluent calls
that all return this, and build() freezes the accumulated state into the
immutable Order:
public final class OrderBuilder
{
private final String customer;
private final String currency;
private final List<Product> items = new ArrayList<>();
private String coupon;
private boolean giftWrapped;
private String note;
public static OrderBuilder of(String customer, String currency) { ... }
public OrderBuilder addItem(Product item) { items.add(item); return this; }
public OrderBuilder coupon(String coupon) { this.coupon = coupon; return this; }
public OrderBuilder giftWrap() { this.giftWrapped = true; return this; }
public OrderBuilder note(String note) { this.note = note; return this; }
public Order build()
{
return new Order(customer, currency, items,
Optional.ofNullable(coupon), giftWrapped, Optional.ofNullable(note));
}
}
Building an order reads as a sentence, and you only mention the parts you actually need:
Order order = OrderBuilder.of("Alice", "EUR")
.addItem(book).addItem(phone)
.coupon("SUMMER").giftWrap()
.build();
The functional approach
Look now at the class diagram of the functional style implementation:

The functional counterpart keeps the same immutable Order target but drops the
mutable accumulator. Each build step becomes a first-class
UnaryOperator<Order> value - a pure function mapping one immutable Order to
the next by returning a modified copy:
public static UnaryOperator<Order> addItem(Product item)
{
return order -> new Order(order.customer(), order.currency(),
Stream.concat(order.items().stream(), Stream.of(item)).toList(),
order.coupon(), order.giftWrapped(), order.note());
}
Because the steps are ordinary values, they are not called on a builder, but they
are composed with andThen, exactly as the factory composed its factory
function and the visitor composed its operations:
Function<Order, Order> config = addItem(book)
.andThen(addItem(phone))
.andThen(coupon("SUMMER"))
.andThen(giftWrap());
Order order = config.apply(OrderBuilder.empty("Alice", "EUR"));
This is more than a stylistic variation. In the OOP version a step is a method call that exists only for the duration of the chain. In the FP version a step is a value which can be stored it in a variable, passed to another method, kept in a list of steps and applied later, or reused the very same step twice:
UnaryOperator<Order> addBook = addItem(book);
Order order = addBook.andThen(addBook).apply(OrderBuilder.empty("Alice", "EUR"));
The object-oriented Builder wraps a stateful object around the immutable target
while the functional one expresses construction as the composition of pure copy
functions over it. “Turtles all the way down”, and both land on the same Order.
Project structure
The code is organized as a multi-module Maven project. The product domain lives
in its own common module: a sealed Product interface, the three product
records and the ProductType enumerated - which already carries the FP factory
function seen above. Everything that can reuse that domain does:
oop-fp-design-patterns (parent POM)
├── common sealed Product, the records, ProductType(+factory)
├── factory (→ common) ProductFactory (OOP); the FP factory *is* common.ProductType
├── visitor (→ common) FP: operations over the common records (switch + lambda bundle)
│ OOP: its own element hierarchy (see below)
└── builder (→ common) immutable Order over the common records; OOP: fluent
OrderBuilder; FP: composed UnaryOperator<Order> steps
The FP factory and the FP visitor both operate directly on the common records,
so nothing is duplicated there. The one exception is the object-oriented
Visitor which needs an accept method on every element (double dispatch), and
since common.Product is sealed it cannot be extended from another module.
The OOP visitor therefore owns its element hierarchy and reuses only the
ProductType enumerated. This asymmetry is not accidental. The classic Visitor
requires every element to expose an accept method, which couples the elements to
the visitor abstraction. These elements must be defined together with the visitor abstraction, and so cannot be
the sealed records defined in common. The functional approach has no such coupling: it
pattern-matches over the sealed type from the outside, so the elements know nothing
about the operations applied to them and, hence, can be the shared common records.
The full code of these examples, including the associated unit tests, can be found here.