DZone
Thanks for visiting DZone today,
Edit Profile
  • Manage Email Subscriptions
  • How to Post to DZone
  • Article Submission Guidelines
Sign Out View Profile
  • Post an Article
  • Manage My Drafts
Over 2 million developers have joined DZone.
Log In / Join
Refcards Trend Reports
Events Video Library
Refcards
Trend Reports

Events

View Events Video Library

Related

  • A Guide to Constructor Chaining in Java
  • Using Java Class Extension Library for Data-Oriented Programming
  • Test Parameterization With JUnit 5.7: A Deep Dive Into @EnumSource
  • JSON Handling With GSON in Java With OOP Essence

Trending

  • Real-World Problems Being Solved by Java
  • WebSockets, gRPC, and GraphQL in the Core
  • Scaling Teams, Scaling Systems: Unlocking Developer Productivity With Platform Engineering
  • Engineering Complexity: Implied vs. Induced Complexity
  1. DZone
  2. Coding
  3. Java
  4. Rethinking Java Design Patterns: From OOP to FP

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.

By 
Nicolas Duminil user avatar
Nicolas Duminil
DZone Core CORE ·
Aug. 04, 26 · Analysis
Likes (0)
Comment
Save
Tweet
Share
227 Views

Join the DZone community and get the full member experience.

Join For Free

The 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:

Object-oriented approach

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:

Java
 
    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:

Java
 
        ...

        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:

Java
 
    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:

Java
 
    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:

Java
 
    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:

Java
 
    @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:

XML
 
    <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:

Java
 
    public final TriFunction<String, String, BigDecimal, Product> factory;

    ProductType (TriFunction<String, String, BigDecimal, Product> factory)
    {
      this.factory = factory;
    }


by this one:

Java
 
    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 standard 20% rate otherwise.
  • Shipping: 10.00 + 2% of the price for (fragile, insured) electronics, a flat 3.00 for books and a flat 5.00 for 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:

Java
 
    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:

Java
 
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:

Java
 
    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:

Object-oriented implementation class diagram

The Functional Approach

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

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:

Java
 
    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:

Java
 
    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:

Java
 
    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:

Java
 
    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:

Java
 
    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:

Java
 
    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:

Object-oriented builder diagram

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:

Java
 
    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:

Java
 
    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:

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:

Java
 
    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:

Java
 
    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:

Java
 
    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:

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:

Java
 
    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:

Java
 
    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:

Java
 
    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:

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:

Java
 
    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:

Java
 
    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:  

Java
 
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.99 fee.
  • Express: 9.99 plus 2% 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:

Object-oriented strategy

The classic Gang of Four Strategy declares an interface for the family of algorithms and one class per algorithm:

Java
 
    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:

Java
 
    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:

Java
 
    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:

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:

Java
 
    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:

Java
 
    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:

Java
 
    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:

Java
 
    ShippingCalculator calculator = new ShippingCalculator(new StandardShipping());
    ...
    BigDecimal total = calculator.total(book);


becomes on the FP side:

Java
 
    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:

Java
 
    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:

Java
 
    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:

Plain Text
 
    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!

Object-oriented programming FP (programming language) Java (programming language)

Published at DZone with permission of Nicolas Duminil. See the original article here.

Opinions expressed by DZone contributors are their own.

Related

  • A Guide to Constructor Chaining in Java
  • Using Java Class Extension Library for Data-Oriented Programming
  • Test Parameterization With JUnit 5.7: A Deep Dive Into @EnumSource
  • JSON Handling With GSON in Java With OOP Essence

Partner Resources

×

Comments

The likes didn't load as expected. Please refresh the page and try again.

  • RSS
  • X
  • Facebook

ABOUT US

  • About DZone
  • Support and feedback
  • Community research

ADVERTISE

  • Advertise with DZone

CONTRIBUTE ON DZONE

  • Article Submission Guidelines
  • Become a Contributor
  • Core Program
  • Visit the Writers' Zone

LEGAL

  • Terms of Service
  • Privacy Policy

CONTACT US

  • 3343 Perimeter Hill Drive
  • Suite 215
  • Nashville, TN 37211
  • [email protected]

Let's be friends:

  • RSS
  • X
  • Facebook