So sánh factory và strategy page năm 2024

This is the second article in my series on design patterns. In the first one, we had a look at the Builder pattern. We also briefly discussed the benefits of patterns. If you haven't read it yet, it might be a good idea to check out the first two paragraphs before you continue with this article.

When I sat down to start planning my next post, I was really torn between the Strategy and Factory patterns. I've used both of them to great effect in my code in the past and I think both of them are fundamental patterns that belong in every OO developer's vocabulary. As it turns out, the Factory pattern complements the Strategy pattern rather nicely, so I decided to cover both in a single post. As was the case with the Builder pattern that we looked at last time, the Factory pattern is a creational pattern. The Strategy pattern, on the other hand, is a behavioural pattern.

The Problem

As before, we'll pretend that we're on a team of Java developers working for a bank. This time round, we're calculating monthly interest on different types of bank accounts. Initially, we're only dealing with two account types - current accounts paying 2% interest per annum, and savings accounts paying 4% per annum. Interest will not be applicable to any other types of accounts. Our account types are defined by an enum.

enum AccountTypes {CURRENT, SAVINGS}

Based on these account types, we write an InterestCalculator class.

public class InterestCalculator {

public double calculateInterest[AccountTypes accountType, double accountBalance] {
    switch [accountType] {
        case CURRENT: return accountBalance * [0.02 / 12];  
        case SAVINGS: return accountBalance * [0.04 / 12];
        default:
            return 0;
    }
}
}

Our next requirement is to add support for two different money market accounts - a standard money market account paying 5% per annum, and a special "high-roller" money market account that pays 7.5%, but only if the customer maintains a minimum balance of R100 000.00. We modify our calculator accordingly.

public class InterestCalculator {

public double calculateInterest[AccountTypes accountType, double accountBalance] {
    switch [accountType] {
        case CURRENT: return accountBalance * [0.02 / 12];  
        case SAVINGS: return accountBalance * [0.04 / 12];
        case STANDARD_MONEY_MARKET: return accountBalance * [0.06/12];
        case HIGH_ROLLER_MONEY_MARKET: return accountBalance < 100000.00 ? 0 : accountBalance * [0.075/12];
        default:
            return 0;
    }
}
}

It should be evident that our code gets messier with every set of new requirements that we implement. We have all these business rules bundled into one class which is becoming harder and harder to understand. Also, rumour has it that the asset financing department of the bank has heard of our new interest calculator, and they would like to use it to calculate interest on loans to customers. However, their interest rates aren't fixed - they are linked to interest rates from a central bank, which we'll have to retrieve via a web service. Not only are we starting to deal with more account types, but the calculation logic is also growing in complexity.

If we keep on adding more and more business rules into our calculator, we're going to end up with something that could become very difficult to maintain. Sure, we can try and extract each calculation into its own method, which might be slightly cleaner, but ultimately, that will still be lipstick on a pig.

The problem that we have is this:

  • We have a single, convoluted, hard-to-maintain method that is trying to deal with a number of different scenarios.

The Strategy pattern can help us to address this issue.

The Pattern[s]

The Strategy pattern allows us to dynamically swop out algorithms [i.e. application logic] at runtime. In our scenario, we want to change the logic used to calculate interest, based on the type of account that we are working with.

Our first step is to define an interface to identify the input and output of our calculations - i.e. the account balance and the interest on that balance.

interface InterestCalculationStrategy {

double calculateInterest[double accountBalance];  
}

Note that our interface is only concerned with the account balance - it doesn't care about the account type, since each implementation will already be specific to a particular account type.

The next step is to create strategies to deal with each of our calculations.

class CurrentAccountInterestCalculation implements InterestCalculationStrategy {

@Override
public double calculateInterest[double accountBalance] {
    return accountBalance * [0.02 / 12];
}
} class SavingsAccountInterestCalculation implements InterestCalculationStrategy {
@Override
public double calculateInterest[double accountBalance] {
    return accountBalance * [0.04 / 12];
}
} class MoneyMarketInterestCalculation implements InterestCalculationStrategy {
@Override
public double calculateInterest[double accountBalance] {
    return accountBalance * [0.06/12];
}
} class HighRollerMoneyMarketInterestCalculation implements InterestCalculationStrategy {
@Override
public double calculateInterest[double accountBalance] {
    return accountBalance < 100000.00 ? 0 : accountBalance * [0.075/12]
}
}

Each calculation is now isolated to its own class, making it much easier to understand individual calculations - they're not surrounded by clutter anymore. Next, we'll refactor our calculator.

public class InterestCalculator {

private final InterestCalculationStrategy currentAccountInterestCalculationStrategy = new CurrentAccountInterestCalculation[];
private final InterestCalculationStrategy savingsAccountInterestCalculationStrategy = new SavingsAccountInterestCalculation[];
private final InterestCalculationStrategy moneyMarketAccountInterestCalculationStrategy = new MoneyMarketInterestCalculation[];
private final InterestCalculationStrategy highRollerMoneyMarketAccountInterestCalculationStrategy = new HighRollerMoneyMarketInterestCalculation[];
public double calculateInterest[AccountTypes accountType, double accountBalance] {
    switch [accountType] {
        case CURRENT: return currentAccountInterestCalculationStrategy.calculateInterest[accountBalance];
        case SAVINGS: return savingsAccountInterestCalculationStrategy.calculateInterest[accountBalance];
        case STANDARD_MONEY_MARKET: return moneyMarketAccountInterestCalculationStrategy.calculateInterest[accountBalance];
        case HIGH_ROLLER_MONEY_MARKET: return highRollerMoneyMarketAccountInterestCalculationStrategy.calculateInterest[accountBalance];
        default:
            return 0;
    }
}
}

We've moved the calculation logic out of the calculator itself, but the code still doesn't look great - it still seems like there are too many things happening in one method. I would even go so far as to call it ugly [but I'm known to be pedantic]. Fortunately, there is an easy way to clean up this mess - the Factory pattern.

The Factory pattern allows us to create objects without necessarily knowing or caring about the type of objects that we are creating. This is exactly what our calculator needs - we want calculations, but we don't care about the details of those calculations. All we really need is a reference to a strategy that knows how to do the appropriate interest calculation for a particular type of account. We can create our factory as follows:

class InterestCalculationStrategyFactory {

private final InterestCalculationStrategy currentAccountInterestCalculationStrategy = new CurrentAccountInterestCalculation[];
private final InterestCalculationStrategy savingsAccountInterestCalculationStrategy = new SavingsAccountInterestCalculation[];
private final InterestCalculationStrategy moneyMarketAccountInterestCalculationStrategy = new MoneyMarketInterestCalculation[];
private final InterestCalculationStrategy highRollerMoneyMarketAccountInterestCalculationStrategy = new HighRollerMoneyMarketInterestCalculation[];
public InterestCalculationStrategy getInterestCalculationStrategy[AccountTypes accountType] {
    switch [accountType] {
        case CURRENT: return currentAccountInterestCalculationStrategy;
        case SAVINGS: return savingsAccountInterestCalculationStrategy;
        case STANDARD_MONEY_MARKET: return moneyMarketAccountInterestCalculationStrategy;
        case HIGH_ROLLER_MONEY_MARKET: return highRollerMoneyMarketAccountInterestCalculationStrategy;
        default: return null;
    }
}
}

You might think that this looks very similar to what we had before. It does, but all of the logic specific to account types is now encapsulated in one class that satisfies the single responsibility principle. The factory isn't concerned with calculations - all it does is to match account types to the appropriate strategies. As a result, we can greatly simplify the code within our calculator class.

public class InterestCalculator {

private final InterestCalculationStrategyFactory interestCalculationStrategyFactory = new InterestCalculationStrategyFactory[];
public double calculateInterest[AccountTypes accountType, double accountBalance] {
    InterestCalculationStrategy interestCalculationStrategy = interestCalculationStrategyFactory.getInterestCalculationStrategy[accountType];
    if [interestCalculationStrategy != null] {
        return interestCalculationStrategy.calculateInterest[accountBalance];
    } else {
        return 0;
    }
}
}

This looks much better than before, but there's still one part of the code that bugs me - that nasty null check. Let's do one more refactoring - we'll introduce a Null Object [also known as a Special Case] to deal with unexpected account types. This simply means that we'll have a default strategy that will be applied as a last resort. It looks as follows.

class NoInterestCalculation implements InterestCalculationStrategy {

@Override
public double calculateInterest[double accountBalance] {
    return 0;
}
}

We can now add NoInterestCalculation to our factory.

class InterestCalculationStrategyFactory {

private final InterestCalculationStrategy currentAccountInterestCalculationStrategy = new CurrentAccountInterestCalculation[];
private final InterestCalculationStrategy savingsAccountInterestCalculationStrategy = new SavingsAccountInterestCalculation[];
private final InterestCalculationStrategy moneyMarketAccountInterestCalculationStrategy = new MoneyMarketInterestCalculation[];
private final InterestCalculationStrategy highRollerMoneyMarketAccountInterestCalculationStrategy = new HighRollerMoneyMarketInterestCalculation[];
private final InterestCalculationStrategy noInterestCalculationStrategy = new NoInterestCalculation[];
public InterestCalculationStrategy getInterestCalculationStrategy[AccountTypes accountType] {
    switch [accountType] {
        case CURRENT: return currentAccountInterestCalculationStrategy;
        case SAVINGS: return savingsAccountInterestCalculationStrategy;
        case STANDARD_MONEY_MARKET: return moneyMarketAccountInterestCalculationStrategy;
        case HIGH_ROLLER_MONEY_MARKET: return highRollerMoneyMarketAccountInterestCalculationStrategy;
        default: return noInterestCalculationStrategy;
    }
}
}

Now that our factory will no longer return nulls, we can refactor the calculator once again. The final version looks like this.

public class InterestCalculator {

public double calculateInterest[AccountTypes accountType, double accountBalance] {
    switch [accountType] {
        case CURRENT: return accountBalance * [0.02 / 12];  
        case SAVINGS: return accountBalance * [0.04 / 12];
        default:
            return 0;
    }
}
}

0

We've effectively removed 75% of the code within the calculator class, and we won't have to come back and change it, regardless of how many new strategies we decide to add. Nice, clean, simple!

Summary

In this article, we looked at an example of code that became overly complex as a result of the fact that it had to alter its logic based on the conditions under which it was executed [i.e. different interest calculations for different account types]. We then extracted the various bits of logic into strategies of their own. Despite this, our code was still fairly complex, since it had knowledge of all of the different strategies that could potentially be used. We addressed this by creating a factory to encapsulate the logic concerned with selecting appropriate strategies for various conditions. Finally, we replaced a null check with a null object, which allowed us to simplify our code even further.

Chủ Đề