This post is Part 1 of a series on utilizing Design Patterns in the context of Functional Programming. If you are interested in learning more, stay tuned for more on my DEV page.
Introduction
Made ubiquitous by the indispensable book Design Patterns: Elements of Reusable Object-Oriented Software written by the Gang of Four, Design Patterns in Software Engineering (SE) are often utilized as battle-tested ways to solve recurrent system design problems in a flexible and reusable way.
However, Design Patterns are more often than not, are utilized in an Object-Oriented Programming (OOP) fashion. Many patterns include the use of abstract classes, interfaces and other OOP features that do not make sense in the context of Functional Programming (FP), where whole systems are composed of functions instead of concrete classes.
Thus, this poses a sort of cognitive mismatch for many engineers who have internalized these OOP patterns and are finding themselves in an environment where FP is growing in popularity.
This post and the subsequent series seeks to provide some instruction and examples on using some of these patterns in the context of FP.
The Strategy Pattern
The Strategy Pattern from the GoF book is described as a way to vary a family of encapsulated algorithms to make them interchangeable from a runtime context. That definition can be a mouthful, so let's break it down.
We have a few components here:
- Algorithm - A procedure that takes some value as input, performs some steps on the input, and produces an output.
- Strategy - A common interface implemented by all Algorithms.
- Context - A Run-time environment or a parent process that seeks to utilize a different algorithm given some condition.
Retail Store Example
To make it easier to understand this, let us adapt this pattern in a real-life context:
Suppose we have a retail store and we want to introduce a way to implement different pricing algorithms to calculate the total price of a customer's order depending on the type of a customer (walk-in or online).
We can first define our pricing algorithms as follows:
Strategy and Algorithms
interface PricingStrategy {
public float getTotalPrice(CustomerOrder);
}
class WalkInPricing implements PricingStrategy {
public float getTotalPrice(CustomerOrder order) {
// Calculate the price per item in order
// according to the walk-in pricing
...
return totalPrice;
}
}
class OnlinePricing implements PricingStrategy {
public float getTotalPrice(CustomerOrder order) {
// Calculate the price per item in order
// according to the online pricing
...
return totalPrice;
}
}
Following that, we can then introduce the strategy and algorithms to the context, like so:
Context
PricingStrategy pricing;
...
// First, we decide type of customer and pricing algorithm to use.
switch(customer.type) {
case "walkIn":
this.pricing = new WalkInPricing();
break;
case "online":
this.pricing = new OnlinePricing();
break;
default:
throw new IllegalCustomerType("...");
}
// Secondly, we get the calculated total price for the order using the pricing algorithm.
this.totalPrice = this.pricing.getTotalPrice(customer.order);
...
This is pretty simple and intuitive!
There are a few benefits to designing the variation this way:
- We avoid implementing a single brittle monolithic algorithm that accounts for all the conditions. Or even implementing the logic in
Context
itself! (e.g. Implementing aPricingAlgorithm
class that has a lot of if/else and switch statements to check the customer type). - We introduce extensibility by encapsulating the algorithms separately. If we want to introduce a new pricing algorithm for a new customer type, we simply need to extend the switch statement in
Context
and have the new pricing algorithm implement theStrategy
interface.
The Functional Approach
Now that we've established how to use the Strategy Pattern in an OOP fashion, how do we implement the same kind of logic in a FP-friendly way?
Well, quite simply we can leverage the idea of higher-order functions, where functions can take in other functions as parameters.
const getMathResult = (mathOperation, arguments) => {
return mathOperation(arguments);
};
This allows us to represent a similar abstraction provided by using an interface in OOP!
Let us explore how we can do this:
const walkInPricing = (orders: CustomerOrder): Number => {
// Calculate the price per item in order
// according to the walk-in pricing
...
return totalPrice;
}
const onlinePricing = (orders: CustomerOrder): Number => {
// Calculate the price per item in order
// according to the online pricing
...
return totalPrice;
}
const getTotalPrice =
(pricingStrategy: (CustomerOrder) => Number,
orders: CustomerOrders): Number => {
return pricingStrategy(orders);
}
...
let pricing;
switch(customer.type) {
case "walkIn":
pricing = walkInPricing;
break;
case "online":
pricing = onlinePricing;
break;
default:
throw new IllegalCustomerType("...");
}
getTotalPrice(pricing, orders);
As you can see, we simply substitute the Strategy interface with the getTotalPrice
function which consumes the pricing
algorithm specified as a parameter and returns us the price for the given function.
This structure allows us to leverage off the same extensibility and encapsulated design that the pattern gives us in the OOP approach! With lesser OOP specific syntax, the code is also shorter and more understandable, which more complex implementations can benefit from.
What's next
This post is Part 1 of a series on utilizing Design Patterns in the context of Functional Programming. If you are interested in learning more about design patterns implementing in the Functional Programming paradigm, stay tuned for more on my DEV page.
Top comments (0)