Ice creams are yummy, and you can almost always customize them to your taste—from the number of scoops, to choosing whether you want a wafer cone or a cup, and even whether or not to dip in chocolate for extra crunch.
We're in charge of designing the billing part, but there's a twist. We'll need to be able to run promotions, offer family discounts, and implement a loyal customer program.
This kind of problem is easily solved using the strategy pattern, as it allows us to reconfigure an algorithm (the billing algorithm) at runtime.
First, let's work out the model. For the sake of simplicity, we put all the items into an enum, IceCreamPart, and attach their prices right into it. We'll also track the number of scoops into it, instead of externally:
enum IceCreamPart {
case waffer
case cup
case scoop(Int)
case chocolateDip
case candyTopping
var price: Double {
switch self {
case .scoop:
return 2.0
default:
return 0.25
}
}
}
Adding a debugging string is always welcome, as this will help ensure our program runs smoothly:
extension IceCreamPart: CustomStringConvertible {
var description: String {
switch self {
case .scoop(let count):
return "\(count)x scoops"
case .waffer:
return "1x waffer"
case .cup:
return "1x cup"
case .chocolateDip:
return "1x chocolate dipping"
case .candyTopping:
return "1x candy topping"
}
}
}
Let's jump into our billing strategies now. BillingStrategy is a simple protocol with a single method:
protocol BillingStrategy {
func add(item: IceCreamPart) -> Double
}
Each time we call the strategy, we want to know the price at which the item will be billed. This will involve calculating the price for each topping and extra scoop added.
Note that we use the unit prices from each item, as they are part of the menu. What we do here is decouple the pricing of the full ice cream from its parts. This lets us run promotions easily, such as halving the price of each scoop after the first one if we need to:
class FullPriceStrategy: BillingStrategy {
func add(item: IceCreamPart) -> Double {
switch item {
case .scoop(let count):
return Double(count) * item.price
default:
return item.price
}
}
}
Let's say the manager wants to offer a promotion. If a customer buys more than two scoops of ice cream, toppings will be offered at half price. Let's add this strategy as well:
class HalfPriceToppings: FullPriceStrategy {
override func add(item: IceCreamPart) -> Double {
if case .candyTopping = item {
return item.price / 2.0
}
return super.add(item: item)
}
}
We use inheritance here, as all prices are related to each other, and we don't want to duplicate the code. In other scenarios, you may not need to use inheritance, as the strategies may not be related to each other.
Now that we have our strategies properly defined, we can get into the Context object. In this scenario, this object will be best represented by the customer Bill. This Bill will have a strategy that we can mutate externally, as well as the ability to add ice-cream items, compute the total, and print the receipt:
struct Bill {
var strategy: BillingStrategy
var items = [(IceCreamPart, Double)]()
init(strategy: BillingStrategy) {
self.strategy = strategy
}
mutating func add(item: IceCreamPart) {
let price = strategy.add(item: item)
items.append((item, price))
}
func total() -> Double {
return items.reduce(0) { (total, item) -> Double in
return total + item.1
}
}
}
extension Bill: CustomStringConvertible {
var description: String {
return items.map { (item) -> String in
return item.0.description + " $\(item.1)"
}.joined(separator: "\n")
+ "\n----------"
+ "\nTotal $\(total())\n"
}
}
The implementation part is complete, and we now can start serving our first customers:
var bill = Bill(strategy: FullPriceStrategy())
// The first customer wants a waffer
bill.add(item: .waffer)
// Then he'll add a single scoop
bill.add(item: .scoop(1))
// Then he'll add the candy toppings
bill.add(item: .candyTopping)
print(bill.description)
// 1x waffer $0.25
// 1x scoops $2.0
// 1x candy topping $0.25
// ----------
// Total $2.5
Let's now welcome the second customer, and start a new bill:
bill = Bill(strategy: FullPriceStrategy())
// This one will be in a cup
bill.add(item: .cup)
// 3 scoops!
bill.add(item: .scoop(3))
// Hooray! Toppings are half price
bill.strategy = HalfPriceToppings()
bill.add(item: .candyTopping)
print(bill)
// 1x cup $0.25
// 3x scoops $6.0
// 1x candy topping $0.125
// ----------
// Total $6.375
Now, the store manager has introduced a loyalty program. When a customer buys five ice creams, they'll get a sixth one for half price. Thanks to the strategy pattern, it's very simple to implement this new pricing strategy, as follows:
class HalfPriceStrategy: FullPriceStrategy {
override func add(item: IceCreamPart) -> Double {
return super.add(item: item) / 2.0
}
}
Now, HalfPriceStrategy can be applied for loyal customers:
bill = Bill(strategy: HalfPriceStrategy())
bill.add(item: .waffer)
bill.add(item: .scoop(1))
bill.add(item: .candyTopping)
print(bill)
// 1x waffer $0.125
// 1x scoops $1.0
// 1x candy topping $0.125
// ----------
// Total $1.25
As we can see, we've been able to add new strategies on the fly to perform the calculations, without cluttering Bill or IceCreamPart.