Rethinking Java Design Patterns: From OOP to FP
This article aims to adopt a more systematic and practical approach to combining Java object-oriented principles in a functional style.
Join the DZone community and get the full member experience.
Join For FreeThe functional programming answer, to those who wonder how to integrate or combine it with object-oriented programming, is usually: Turtles all the way down.
This is an aphorism whose origin is credited to Richard Feynman. In his book, Surely You're Joking, Mr. Feynman !, published in 1985, he tells the story of one of his conferences on the nature of the universe, where he was challenged by someone in the audience, saying that the universe rests on a turtle. Feynman asked then what the turtle is resting on, and the answer was: "another bigger turtle".
And when he smugly asked what the bigger turtle is resting on, the attendee said: "It's turtles all the way down, you can't trick me !"
This metaphor is often used in the context of functional programming to describe an infinite series of entities governed by a recursive principle. And it's also the answer of functional programming to developers coming from an object-oriented mindset: "just do functional all the way down." But to adopt a more systematic approach to combining object-oriented principles with a functional style, a more practical answer is required, and this is what I'm trying to do here.
We, as developers, fortunately don't have to reinvent the wheel. All the problems are solved nowadays, especially since LLM agents became the most common digital infrastructure. But as surprising as it might seem to our younger colleagues, who can't live 48 hours without AI, even before LLMs, a general approach fitting solutions to problems existed, in the form of design patterns.
As a matter of fact, object-oriented programming proposes repeatable solutions tested, proven, and formalized, called design patterns, that you most likely already used, even if you aren't aware of it.
The Gang of Four classified these patterns into three groups:
- Behavioral patterns, which deal with responsibilities and communication between objects.
- Creational patterns that abstract the object creation/instantiation process.
- Structural patterns that compose objects such that they form larger or enhanced ones.
Let's take some of the most commonly used patterns in each category and see how to combine their object-oriented inherent nature with a more functional approach.
The Factory
This design pattern belongs to the creational category, and its purpose is to instantiate objects without exposing implementation details.
The Object-Oriented Approach
The figure below shows the class diagram of a factory design pattern:

Our scenario here is a simple one: a Product interface implemented by three classes: BookProduct, ElectronicProduct and FashionProduct. They can be created through the ProductFactory class, as follows:
public class ProductFactory
{
public static Product newProduct (String name, String description,
BigDecimal price, ProductType productType)
{
Objects.requireNonNull(name, "Name is null");
...
return switch (productType)
{
case BOOK -> new BookProduct(name, description, price);
case ELECTRONIC -> new ElectronicProduct(name, description, price);
case FASHION -> new FashionProduct(name, description, price);
default -> throw new IllegalArgumentException ("Unknown type: %s".formatted(productType));
};
}
}
Using this factory, it's very easy to create a BookProduct, for example, while avoiding to expose implementation details:
...
Product product = ProductFactory.newProduct("Book1", "A book",
new BigDecimal("20.50"), ProductType.BOOK);
...
As you probably noticed, the ProductType enumerated defines the three categories. If a new product is to be introduced, the factory has to be modified to reflect this business change. And this interdependence of the factory and the enumerated makes the whole approach fragile.
In order to reduce this fragility, we need to introduce a compile-time validation with a more functional approach.
The Functional Approach
Our example is an over-simplified case of a product management system. The presented factory instantiates different simple records having the same arguments. These identical constructors give us the possibility to move the factory directly into the ProductType enumerated, such that any new product automatically requires a corresponding factory.
Java enum types are based on constant names, but we can attach to each one its corresponding value. Or, even better, a factory function for creating discrete products. Look at that:
public enum ProductType
{
ELECTRONIC(ElectronicProduct::new),
FASHION(FashionProduct::new),
BOOK(BookProduct::new);
public final TriFunction<String, String, BigDecimal, Product> factory;
ProductType (TriFunction<String, String, BigDecimal, Product> factory)
{
this.factory = factory;
}
public Product newInstance (String name, String description, BigDecimal price)
{
Objects.requireNonNull(name, "Name is null");
...
return this.factory.apply (name, description, price);
}
}
Now, creating a new Product instances is easier:
Product product = ProductType.BOOK.newInstance("Book1",
"A book", new BigDecimal("20.45"));
The public property factory seems redundant now that a dedicated method for the instance creation is available. But it provides a very convenient functional way to interact further with the factory. For example:
ProductType.BOOK.factory.andThen(showThePrice).apply("Book1",
"A book", new BigDecimal("20.45"));
as shown in the TestProductFactory class, in the fp_design_paterns.factorypackage. Of course, given that our products need three-argument constructors and since Java doesn't provide an equivalent of the BiFunction class, but with three input arguments, you will need to craft a TriFunction class, as shown below:
@FunctionalInterface
public interface TriFunction<A, B, C, R>
{
R apply(A a, B b, C c);
default <K> TriFunction<A, B, C, K> andThen(Function<? super R, ? extends K> f)
{
Objects.requireNonNull(f);
return (A a, B b, C c) -> f.apply(apply(a, b, c));
}
}
You can do that or, if like me, you prefer to use a reliable library, then Vavr already defines a Function3 interface that has the behavior you want. Just include the following Maven dependency:
<dependency>
<groupId>io.vavr</groupId>
<artifactId>vavr</artifactId>
<version>1.0.1</version>
</dependency>
This library is a good choice if you need to define functions with up to 8 arguments. Then, you just need to replace, in ProductType, the following definition:
public final TriFunction<String, String, BigDecimal, Product> factory;
ProductType (TriFunction<String, String, BigDecimal, Product> factory)
{
this.factory = factory;
}
by this one:
public final Function3<String, String, BigDecimal, Product> factory;
ProductType (Function3<String, String, BigDecimal, Product> factory)
{
this.factory = factory;
}
The Visitor
This design pattern belongs to the behavioral category and its purpose is to add new operations to an existing object hierarchy without modifying the classes of that hierarchy. It is the classic answer to the expression problem: When the set of types is stable, but the set of operations grows, the Visitor lets you keep adding operations cheaply.
We reuse the same domain as the factory: a Product implemented by BookProduct, ElectronicProduct and FashionProduct. To give the visitor a reason to exist, each operation now behaves differently per product type:
- VAT: a reduced
5.5%rate for books, the standard20%rate otherwise. - Shipping:
10.00 + 2%of the price for (fragile, insured) electronics, a flat3.00for books and a flat5.00for fashion. - Discount:
10%for electronics,5%for books,15%for fashion.
The Object-Oriented Approach
The classic Visitor relies on double dispatch. Each Product accepts a visitor and calls back the overload matching its own type:
public interface Product
{
...
<R> R accept(ProductVisitor<R> visitor);
}
public record BookProduct (String name, String description, BigDecimal price) implements Product
{
...
public <R> R accept(ProductVisitor<R> visitor)
{
return visitor.visit(this);
}
}
The operation lives in a generic visitor, one `visit` overload per concrete type:
public interface ProductVisitor<R>
{
R visit(ElectronicProduct product);
R visit(BookProduct product);
R visit(FashionProduct product);
}
Computing the VAT of any product is then a matter of applying a concrete visitor:
BigDecimal vat = book.accept(new VatVisitor());
Adding a new operation (shipping, discount, ...) only requires a new ProductVisitor implementation as the Product implementation classes never change. This is the reverse of the trade-off the factory made: it made adding a new operation easy, but a new product type is more expensive to add as you must edit its central switch. The visitor makes adding a new operation free but shifts that same cost onto types, since a new product type now forces every visitor to be updated. It is the classic expression problem: you can make types cheap to add or operations cheap to add, but not both.
The following figure shows the object-oriented implementation class diagram:

The Functional Approach
Look now at the class diagram of the Visitor functional style implementation:

In modern Java, the functional counterpart of the Visitor is exhaustive pattern matching over a sealed type. We first seal the hierarchy:
public sealed interface Product permits ElectronicProduct, BookProduct, FashionProduct
{
...
}
An operation is then just a Function<Product, R> built on a switch that deconstructs each record. Because Product is sealed, the compiler proves the switch is exhaustive — no default branch, no double dispatch, no accept:
public static final Function<Product, BigDecimal> VAT = product -> switch (product)
{
case BookProduct(String name, String description, BigDecimal price) ->
amount(price, "0.055");
case ElectronicProduct(String name, String description, BigDecimal price) ->
amount(price, "0.20");
case FashionProduct(String name, String description, BigDecimal price) ->
amount(price, "0.20");
};
Being ordinary functions, these operations compose:
ProductOperations.DISCOUNT.andThen(amount -> "discount=" + amount).apply(fashion);
Between the classic Visitor and pure pattern matching sits an intermediate step: the visitor as a bundle of functions, one lambda per type, instead of an interface with one method per type:
public record ProductVisitor<R>(
Function<ElectronicProduct, R> onElectronic,
Function<BookProduct, R> onBook,
Function<FashionProduct, R> onFashion)
{
public R visit(Product product)
{
return switch (product)
{
case ElectronicProduct e -> onElectronic.apply(e);
case BookProduct b -> onBook.apply(b);
case FashionProduct f -> onFashion.apply(f);
};
}
}
Which makes an operation a value you can assemble on the fly:
ProductVisitor<BigDecimal> vat = new ProductVisitor<>(
e -> ..., b -> ..., f -> ...);
BigDecimal amount = vat.visit(book);
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 are 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 factoryfunction 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 that can be stored 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.
The Decorator
This design pattern belongs to the structural category, and its purpose is to attach additional responsibilities to an object dynamically by wrapping it in another object that shares the same interface. It is the flexible alternative to subclassing for extending behavior: rather than a combinatorial explosion of DiscountedTaxedGiftWrappedProduct subclasses, you wrap a product in as many independent decorators as you need, and they stack.
We reuse the same Product domain. Each decorator changes the price() and the description() while leaving everything else untouched. To keep the pattern visibly distinct from the visitor, whose rules varied per product type, the decorators here apply the same rule to every product:
- Discounted: 10% off the wrapped price.
- Taxed: adds 20% VAT to the wrapped price.
- GiftWrapped: adds a flat `5.00` wrapping fee.
Because they stack, a 100.00 book decorated Discounted → Taxed→GiftWrapped goes 100.00 → 90.00 → 108.00 → 113.00, and its description reads "A book discounted, VAT incl., gift-wrapped."
The Object-Oriented Approach
The figure below shows the class diagram of the object-oriented decorator:

The classic Gang of Four Decorator is an object that implements the component interface and holds a reference to another component, delegating the untouched operations and overriding the ones it enhances. An abstract ProductDecorator captures the delegation once:
public abstract class ProductDecorator implements Product
{
protected final Product product;
protected ProductDecorator(Product product)
{
this.product = Objects.requireNonNull(product, "Product is null");
}
public String name() { return product.name(); }
public String description() { return product.description(); }
public BigDecimal price() { return product.price(); }
public ProductType type() { return product.type(); }
}
Each concrete decorator then overrides only what it changes:
public class Discounted extends ProductDecorator
{
private static final BigDecimal RATE = new BigDecimal("0.10");
public Discounted(Product product) { super(product); }
public BigDecimal price()
{
return product.price().subtract(amount(product.price(), RATE));
}
public String description()
{
return product.description() + " (discounted)";
}
}
Since a decorator is a Product, decorators wrap decorators, and the enhancements compose by nesting:
Product wrapped = new GiftWrapped(new Taxed(new Discounted(new BaseProduct(book))));
BigDecimal price = wrapped.price(); // 113.00
The leaf being wrapped is a BaseProduct, a small record that adapts a shared common.Product into the decorator's own interface. This is necessary because common.Product is sealed and so, exactly like the object-oriented visitor, the decorator cannot make the common records implement its interface directly.
The Functional Approach
Look now at the class diagram of the functional style implementation:

The functional counterpart of a decorator is simply a function which maps a product to an enhanced product and implemented as an UnaryOperator<Product>. Because the common records are immutable, "enhancing" one means rebuilding it through the ProductType factory, already seen at the very beginning, which is why the FP side reuses common directly with no adapter:
public static final UnaryOperator<Product> DISCOUNTED = product ->
product.type().newInstance(product.name(),
product.description() + " (discounted)",
product.price().subtract(amount(product.price(), "0.10")));
Being ordinary values, the decorations compose with andThen, exactly as the factory composed its factory function, the visitor composed its operations, and the builder composed its steps:
UnaryOperator<Product> decorate = DISCOUNTED.andThen(TAXED).andThen(GIFT_WRAPPED);
Product wrapped = decorate.apply(book); // price 113.00
And, just like the functional builder step, a decoration is a reusable first-class value. For example, the same discount could be applied twice:
Product wrapped = DISCOUNTED.andThen(DISCOUNTED).apply(book); // 100 -> 90 -> 81
The object-oriented Decorator wraps the component in a stack of objects sharing its interface, while the functional one expresses the very same stacking as the composition of pure Product to Product functions. "Turtles all the way down", and both land on the same enhanced product.
The Strategy
This design pattern belongs to the behavioral category, and its purpose is to define a family of algorithms, encapsulate each one of them, and make them interchangeable, such that the algorithm may vary independently of the client using it. Where the decorator asked what else should happen to this object ?, the strategy asks which one of these algorithms should be applied ?.
We keep the same Product domain and we compute a shipping cost for it. Three interchangeable algorithms are provided:
- Standard: a flat
4.99fee. - Express:
9.99plus2%of the product price. - FreeOver: the familiar "free delivery over 50.00" commercial rule. It is parameterized by a price threshold and by the strategy to apply when the threshold isn't reached: should the product price be greater than or equal to the threshold, the shipping is free; otherwise, the product doesn't qualify, and the cost is the one computed by that other strategy.
For our 100.00 book, the standard shipping costs 4.99 and the express one costs 11.99. As for the free-over one, with a threshold of 50.00 and a StandardShipping()strategy, the cost is 0.00, since 100.00 is above the threshold. Raising that same threshold to 150.00 falls back to the standard shipping and, hence, the cost is 4.99.
Notice that, unlike the visitor, nothing here varies per product type: what varies is the algorithm, and it is the caller that picks it.
The Object-Oriented Approach
The figure below shows the class diagram of the object-oriented strategy:

The classic Gang of Four Strategy declares an interface for the family of algorithms and one class per algorithm:
public interface ShippingStrategy
{
BigDecimal cost(Product product);
}
public class ExpressShipping implements ShippingStrategy
{
private static final BigDecimal FEE = new BigDecimal("9.99");
private static final BigDecimal RATE = new BigDecimal("0.02");
public BigDecimal cost(Product product)
{
return FEE.add(product.price().multiply(RATE).setScale(2, RoundingMode.HALF_UP));
}
}
StandardShipping and ExpressShipping are stateless, their fees being constants. But an algorithm that needs to be parameterized has nowhere to keep its parameters other than instance fields and, hence, becomes a class with state. This is the case of FreeOverShipping, which holds both its threshold and the strategy to fall back to below it, every such pair defining a different algorithm:
public class FreeOverShipping implements ShippingStrategy
{
private final BigDecimal threshold;
private final ShippingStrategy otherwise;
public FreeOverShipping(BigDecimal threshold, ShippingStrategy otherwise) { ... }
public BigDecimal cost(Product product)
{
return product.price().compareTo(threshold) >= 0 ? FREE : otherwise.cost(product);
}
}
Last but not least, the context is the object that uses the algorithm without knowing which one it is. It only holds a reference to the interface, which is what allows the algorithm to be replaced at runtime:
ShippingCalculator calculator = new ShippingCalculator(new StandardShipping());
BigDecimal cost = calculator.cost(book); // 4.99
BigDecimal total = calculator.total(book); // 104.99
calculator.setStrategy(new ExpressShipping());
cost = calculator.cost(book); // 11.99
total = calculator.total(book); // 111.99
Contrary to the visitor and to the decorator, the strategy doesn't require anything at all from the elements it processes: no `accept` method and no shared component interface. Consequently, and this is the first time it happens on the object-oriented side, the module reuses the sealed common.Product directly, with neither its own hierarchy, nor any adapter.
The Functional Approach
Look now at the class diagram of the functional style implementation:

Of all the patterns seen so far, this is the one where the functional answer is the most radical. The interface ShippingStrategy in the OO implementation declares one single method and holds no state, such that everything it tells us is a Product comes in, a BigDecimal comes out. In functional terms, it is nothing more than a Function<Product, BigDecimal> type. So each algorithm becomes a plain value of the function type, for example:
public static final Function<Product, BigDecimal> EXPRESS = product ->
EXPRESS_FEE.add(product.price().multiply(EXPRESS_RATE).setScale(2, RoundingMode.HALF_UP));
As opposed to the OO side, which required the FreeOverShipping class holding the threshold and the shipping strategy, the FP side captures them in a closure. So this class on the OO side becomes on the FP side a higher-order function, i.e. a function returning the strategy itself:
public static Function<Product, BigDecimal> freeOver(BigDecimal threshold,
Function<Product, BigDecimal> otherwise)
{
return product -> product.price().compareTo(threshold) >= 0 ? FREE : otherwise.apply(product);
}
The very same happens to ShippingCalculator, the context class on the OOP side. Its whole reason to exist was to hold a strategy in a field, such that its cost()and total() operations could delegate to it. But a context is just an operation parameterized by an algorithm and this, once again, is precisely a higher-order function. Hence, the ShippingCalculator.total() method becomes:
public static Function<Product, BigDecimal> totalWith(Function<Product, BigDecimal> strategy)
{
return product -> product.price().add(strategy.apply(product));
}
such that the following call on the OO side:
ShippingCalculator calculator = new ShippingCalculator(new StandardShipping());
...
BigDecimal total = calculator.total(book);
becomes on the FP side:
BigDecimal total = totalWith(STANDARD).apply(book);
There is no field to hold the strategy anymore and, consequently, no setStrategy()method either. Here the strategy is an argument which doesn't need to be stored in the context, just call the function with the right value.
But the real advantage of the strategies as ordinary values is that they can be combined. Picking the cheapest of several shipping options requires yet another class on the OO side, while here it's a simple combinator:
Function<Product, BigDecimal> best = cheapest(STANDARD, EXPRESS); // 4.99
And as usual, they compose with andThen, for example to apply a promotion to whatever cost has been computed:
Function<Product, BigDecimal> promo =
EXPRESS.andThen(cost -> cost.divide(TWO, 2, RoundingMode.HALF_UP)); // 6.00
The OO Strategy encapsulates each algorithm in a class implementing a common interface and injects the chosen one into a context object, while the functional one observes that such an interface describes nothing but a function type which the JDK already provides and, consequently, keeps only the algorithms themselves. "Turtles all the way down", and both compute the same cost.
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
├── decorator (→ common) FP: composed UnaryOperator<Product> decorations over the
│ common records; OOP: its own Product interface (see below)
└── strategy (→ common) shipping algorithms over the common records; OOP: the
ShippingStrategy hierarchy + context; FP: plain
Function<Product, BigDecimal> values
The FP factory, the FP visitor and the FP decorator all operate directly on the common records, so nothing is duplicated there, and the Strategy does so on both of its sides. The two exceptions are the object-oriented Visitor and the object-oriented Decorator.
The Visitor needs an accept method on every element (double dispatch). The Decorator needs a non-sealed Product interface that its wrappers can implement. In both cases, common.Product is sealed and cannot be extended from another module, so each owns its own element/component types and reuses only the ProductType enumerated. The OOP decorator bridges back to common through a small BaseProduct adapter. This asymmetry is not accidental.
The classic Visitor requires every element to expose an accept method, and the classic Decorator requires every component to share the wrappers' interface. Both couple the elements to the pattern's abstraction, so they cannot be the sealed records defined in common.
The functional approach has no such coupling: it operates over the sealed type from the outside, pattern-matching for the visitor, rebuilding through the factory for the decorator, so the elements know nothing about the operations applied to them and, hence, can be the shared common records.
The Strategy confirms the rule the other way around: it doesn't couple the elements to its abstraction either, only the client to it, and this is precisely why it is the only pattern here whose object-oriented implementation reuses `common` as freely as its functional one.
The full code of these examples, including the associated unit tests, can be found here.
Have a great summer, everyone!
Published at DZone with permission of Nicolas Duminil. See the original article here.
Opinions expressed by DZone contributors are their own.
Comments