Choose Your Strategy

Using the Strategy Pattern to effectively deal with conditions, without making a mess of your codebase.

I'm currently working on a project that spans multiple teams across our organisation, as we touch all the areasincluding Payments. Without going into detail, the tax rules are different for this project than for our core product (which is tax exempt). Additionally, we already sell a different type of product (in our nomenclature, "product family") that we must charge tax onthese are our "marketplace" products.

Essentially, the logic would be the following:

func (c Calculator) GetTotal(p Product) float64 {
    subtotal := p.Price

    var taxAmount float64

    if p.Family == "marketplace" {
         // Marketplace products we already charge tax on
        taxAmount = c.calculateTax(p)
    } else if strings.Contains(p.Name, "new") {
        if p.Price < 100.0 {
             // New product we're adding
            taxAmount = c.calculateDifferentTax(p)
        } else {
             // Reduced tax rate for more expensive product
            taxAmount = c.calculateReducedDifferentTax(p)
        }
    } else {
         // Don't charge tax on our core product
        taxAmount = 0.0
    }

    return subtotal + taxAmount
}

Yuck.

Now, obviously this code is simplified and a little contrived, but it's also easy to see how rapidly this could grow out of control. How long before the next business rule? How long before the ninth else if?

The team who own this code are already looking at me sideways for asking them to do this, and I completely understand why. However, this one weird trick for dealing with this will shock you.

Building a Strategy

A strategy is essentially an interface which we can implement in various ways, and inject. We still retain the business logic of if / else / if / else etc., but by abstracting that away we keep from polluting our code. So let's build the interface, and some concrete implementations:

// Strategy interface
type TaxCalculatorStrategy interface {
    Calculate(p Product) float64
}

// Implementation for Marketplace products
type MarketplaceTaxCalculator struct {}

func (MarketplaceTaxCalculator) Calculate(p Product) float64 {
    return p.Price * 0.2
}

// Implementation for our Core product
type CoreTaxCalculator struct {}

func (CoreTaxCalculator) Calculate(p Product) float64 {
    return 0.0
}

// Our new implementation
type NewProductTaxCalculator struct {}

func (NewProductTaxCalculator) Calculate(p Product) float64 {
    if p.Price < 100.0 {
        return p.Price * 0.15
    }

    return p.Price * 0.12
}

Now, when we come to our GetTotal method, we can simply inject the strategy to use for calculating taxes.

func (c Calculator) GetTotal(p Product, s TaxCalculatorStrategy) float64 {
    return p.Price * s.Calculate()
}

So what?

This may look at first glance like we've just written a whole bunch more code to maintain, and moved the problem elsewhere. However, using the Strategy Pattern gives us a few distinct advantages.

Firstly, we're able to express our business logic in a more more object-oriented way. The tenets of Domain Driven Design teach us that first and foremost, our software is an abstracted model of the business, and the closer that model to the way the overall business operates, the better.

Secondly, this helps drastically with software maintenance. It's hard to demonstrate this with a contrived example in a blog post, but imagine that our initial set of if statements were one of many; spaghetti logic, if you will. Rather than littering our codebase with conditionals throughout, we can choose our set of strategies to pass through the program near the beginning of the process, in one place. This may also reduce the amount of information that deeper parts of the logic need to be aware of, helping us stick to the Open-Closed Principle.

Third, by unravelling our noodles into discrete strategies, we can become much more sure that future tweaks to one product's tax rules won't affect the others, as they're encapsulated in their own object (and likely their own set of files). If the code were already organised in this way, adding our new strategy would have next to zero chance to mess up an existing set of tax rules. This, coupled with the increased testability of this pattern, can really help cut down on bugs in future.