Click here to Skip to main content
65,938 articles
CodeProject is changing. Read more.
Articles / Languages / C#

Who cares about Domain Rules?

4.96/5 (88 votes)
20 Nov 2006MIT12 min read 1   85  
With this article, I am evolving a domain problem towards the best possible solution.

Sample Image - Metrics Compared.jpg

Introduction

This article shows how you can solve a domain problem, with several different solutions. In the graph above, you can see the six solution names. I have explored each solution using exactly the same problem.

I've always said that there is no wrong way to develop code. What do I mean by that? Well, you could solve a problem in several ways. Some solutions may be more elegant, others have better performance. But personally, I prefer the solution that would make maintenance the easiest. So how do you know which solution is the best? That is exactly what I will try to answer in this article.

Software Development techniques have evolved considerably in the last two decades. When I started programming in Visual Basic 4, we relied on procedural programming to solve our software problems. Object-Orientation was mainly academic and not widely used. Then I learned Java, and my world changed considerably. Inheritance and polymorphism opened so many new possibilities. Today, the rise of dynamic languages is taking centre stage, and soon we will shift to a new paradigm.

Background

The problem domain we would like to solve is as follows:

  1. We need to represent the concept of an Investor in our application.
  2. Our Investor should know its marginal tax rate and tax liability calculated from the investor’s income.
  3. The income tax laws for the following countries should be incorporated:
    • USA.
    • Australia.
    • New Zealand.
  4. If none of these countries are specified, the object should have non-exceptional behavior.

What does non-exceptional behavior mean? The application should provide intelligent do nothing behaviors if none of the countries are specified, by hiding the details from its collaborators.

1. Procedural Object-Orientation

Now please don't flame me about the title.

What I mean by Procedural Object-Orientation is code that is written in OO containers like classes, but any program logic captured in these objects are written procedural. Code written in this manner tends to have big methods with numerous conditional constructs. Here is the class diagram of the Investor class written in the procedural manner:

Image 2

In the code, concentrate on the tax rate and tax liability properties where the Investor class specifies the tax behavior for each country. As you can see, the code is pretty laborious with many logic constructs.

C#
public class Investor
{
    private double income;
    private string m_currentCultureName = CultureInfo.CurrentCulture.Name;
    public Investor(double income)
    {
        this.income = income;
    }
    public double Income
    {
        get { return income; }
        set { income = value; }
    }
    public double TaxLiability
    {
        get
        {
            double taxLiability = 0.0;
            double inv_income = Income;
            switch (CurrentCultureName)
            {
                case "en-NZ":
                    // Calculate the Tax Liability for the Investor according
                    // to the tax laws of the New Zealand
                    if (inv_income > 19500.99)
                    {
                        taxLiability += 19500.99 * 0.195;
                    }
                    if (inv_income <= 19500.99)
                    {
                        taxLiability += inv_income * 0.195;
                    }
                    if (inv_income > 60000.99)
                    {
                        taxLiability += (60000.99 - 19501.00) * 0.33;
                    }
                    if (inv_income >= 19501.00 && inv_income <= 60000.99)
                    {
                        taxLiability += (inv_income - 19501.00) * 0.33;
                    }
                    if (inv_income > 60000.99)
                    {
                        taxLiability += (inv_income - 60000.99) * 0.39;
                    }
                    break;
                case "en-AU":
                    // Calculate the Tax Liability for the Investor according
                    // to the tax laws of the Australia
                    if (inv_income >= 6001.00 && inv_income <= 25000.99)
                    {
                        taxLiability += (inv_income - 6000.99) * 0.15;
                    }
                    if (inv_income > 25000.99)
                    {
                        taxLiability += (25000.99 - 6001.00) * 0.15;
                    }
                    if (inv_income > 75000.99)
                    {
                        taxLiability += (75000.99 - 25001.00) * 0.30;
                    }
                    if (inv_income >= 25001.00 && inv_income <= 75000.99)
                    {
                        taxLiability += (inv_income - 25001.00) * 0.30;
                    }
                    if (inv_income > 150000.99)
                    {
                        taxLiability += (150000.99 - 75001.00) * 0.40;
                    }
                    if (inv_income >= 75001.00 && inv_income <= 150000.99)
                    {
                        taxLiability += (inv_income - 75001.00) * 0.40;
                    }
                    if (inv_income > 150000.99)
                    {
                        taxLiability += (inv_income - 150000.99) * 0.45;
                    }
                    break;
                case "en-US":
                    // Calculate the Tax Liability for the Investor according
                    // to the tax laws of the USA
                    if (inv_income > 7550.99)
                    {
                        taxLiability += 7550.99 * 0.10;
                    }
                    if (inv_income >= 0.0 && inv_income <= 7550.99)
                    {
                        taxLiability += inv_income * 0.10;
                    }
                    if (inv_income > 30650.99)
                    {
                        taxLiability += (30650.99 - 7551.00) * 0.15;
                    }
                    if (inv_income >= 7551.00 && inv_income <= 30650.99)
                    {
                        taxLiability += (inv_income - 7551.00) * 0.15;
                    }
                    if (inv_income > 74200.99)
                    {
                        taxLiability += (74200.99 - 30651.00) * 0.25;
                    }
                    if (inv_income >= 30651.00 && inv_income <= 74200.99)
                    {
                        taxLiability += (inv_income - 30651.00) * 0.25;
                    }
                    if (inv_income > 154800.99)
                    {
                        taxLiability += (154800.99 - 74201.00) * 0.28;
                    }
                    if (inv_income >= 74201.00 && inv_income <= 154800.99)
                    {
                        taxLiability += (inv_income - 74201.00) * 0.28;
                    }
                    if (inv_income > 336550.99)
                    {
                        taxLiability += (336550.99 - 154801.00) * 0.33;
                    }
                    if (inv_income >= 154801.00 && inv_income <= 336550.99)
                    {
                        taxLiability += (inv_income - 154801.00) * 0.33;
                    }
                    if (inv_income > 336551.00)
                    {
                        taxLiability += (inv_income - 336551.00) * 0.35;
                    }
                    break;
                default:
                    taxLiability = 0.0;
                    break;
            }
            return taxLiability;
        }
    }
    public double TaxRate
    {
        get
        {
            double taxRate;
            double inv_income = Income;
            switch (CurrentCultureName)
            {
                case "en-NZ": // Calculate the tax rate for New Zealand
                    if (inv_income >= 0.0 && inv_income <= 19500.99)
                        taxRate = 0.195;
                    else if (inv_income >= 19501.00 && inv_income <= 60000.99)
                        taxRate = 0.33;
                    else
                        taxRate = 0.39;
                    break;
                case "en-AU": // Calculate the tax rate for Australia
                    if (inv_income >= 0.0 && inv_income <= 6000.99)
                        taxRate = 0.0;
                    else if (inv_income >= 6001.00 && inv_income <= 25000.99)
                        taxRate = 0.15;
                    else if (inv_income >= 25001.00 && inv_income <= 75000.99)
                        taxRate = 0.30;
                    else if (inv_income >= 75001.00 && inv_income <= 150000.99)
                        taxRate = 0.40;
                    else
                        taxRate = 0.45;
                    break;
                case "en-US":
                // Calculate the tax rate for the United States of America
                    if (inv_income >= 0.0 && inv_income <= 7550.99)
                        taxRate = 0.10;
                    else if (inv_income >= 7551.00 && inv_income <= 30650.99)
                        taxRate = 0.15;
                    else if (inv_income >= 30651.00 && inv_income <= 74200.99)
                        taxRate = 0.25;
                    else if (inv_income >= 74201.00 && inv_income <= 154800.99)
                        taxRate = 0.28;
                    else if (inv_income >= 154801.00 && inv_income <= 336550.99)
                        taxRate = 0.33;
                    else
                        taxRate = 0.35;
                    break;
                default:
                    taxRate = 0.0;
                    break;
            }
            return taxRate;
        }
    }
    public string CurrentCultureName
    {
        get { return m_currentCultureName; }
        set { m_currentCultureName = value; }
    }
}

I can quickly identify the following problems in the above code:

  1. Copy/Paste Errors - copy and paste is normally considered a very bad practice when programming. Because the code is so similar, you copy the code and paste it in the next case label. Then, change a few variables and/or the values to be evaluated. This makes it easy to miss a variable or magic number you need to change, creating logic errors that are extremely hard to find.
  2. Duplicate Code - Duplicate code tends to make maintenance extremely hard. When you have to make a change to a rule, you tend to search through code to find all the places where you need to make the change. This is very costly and very error prone. A good principle to follow when writing code is the DRY principle (Don't Repeat Yourself).
  3. Big Methods - Since the early days of programming, people have realized that the longer a procedure is, the more difficult it is to understand. So it is always prudent to write small well structured methods.
  4. Comments - Not all comments are bad. But many comments are an indication that something is too complex, that a business rule isn't being enforced, or that a method or class is doing too much in one place. The reason the comments in this specific example are bad, is because the comments are needed to explain what the code is doing. You will see in later examples that the comments are completely unnecessary when the code is structured well.
  5. Switch Statement - The problem with switch statements is essentially that of duplication. Often you find the same switch statement scattered about a program in different places.

Is writing code in this fashion wrong?

In my opinion, yes. Just because of the maintenance nightmare. If we look at some code metrics on this class, we see a really high Cyclomatic Complexity of 37 on the tax liability property. For more information on code metrics, see this article.

2. Evolution One - Extract Interface

In this evolution of the problem, we use an interface as our contract to specify what an Investor implementation should look like. Now we are able to create a specific implementation for each country. This allows us the opportunity to split the tax rate and tax liability property code into each of their respective classes.

Image 3

Here is the interface:

C#
public interface IInvestor
{
    double Income { get; set; }
    double TaxLiability { get; }
    double TaxRate { get; }
}

Here is the implementation of the Investor class for the United States.

C#
public class USAInvestor : IInvestor
{
    private double income;
    private double taxRate;
    public USAInvestor(double income)
    {
        this.income = income;
    }
    public double Income
    {
        get { return income; }
        set { income = value; }
    }
    public double TaxLiability
    {
        get
        {
            double taxLiability = 0.0;
            double inv_income = Income;
            if (inv_income > 7550.99)
            {
                taxLiability += 7550.99 * 0.10;
            }
            if (inv_income >= 0.0 && inv_income <= 7550.99)
            {
                taxLiability += inv_income * 0.10;
            }
            if (inv_income > 30650.99)
            {
                taxLiability += (30650.99 - 7551.00) * 0.15;
            }
            if (inv_income >= 7551.00 && inv_income <= 30650.99)
            {
                taxLiability += (inv_income - 7551.00) * 0.15;
            }
            if (inv_income > 74200.99)
            {
                taxLiability += (74200.99 - 30651.00) * 0.25;
            }
            if (inv_income >= 30651.00 && inv_income <= 74200.99)
            {
                taxLiability += (inv_income - 30651.00) * 0.25;
            }
            if (inv_income > 154800.99)
            {
                taxLiability += (154800.99 - 74201.00) * 0.28;
            }
            if (inv_income >= 74201.00 && inv_income <= 154800.99)
            {
                taxLiability += (inv_income - 74201.00) * 0.28;
            }
            if (inv_income > 336550.99)
            {
                taxLiability += (336550.99 - 154801.00) * 0.33;
            }
            if (inv_income >= 154801.00 && inv_income <= 336550.99)
            {
                taxLiability += (inv_income - 154801.00) * 0.33;
            }
            if (inv_income > 336551.00)
            {
                taxLiability += (inv_income - 336551.00) * 0.35;
            }
            return taxLiability;
        }
    }
    public double TaxRate
    {
        get
        {
            double inv_income = Income;
            if (inv_income >= 0.0 && inv_income <= 7550.99)
                taxRate = 0.10;
            else if (inv_income >= 7551.00 && inv_income <= 30650.99)
                taxRate = 0.15;
            else if (inv_income >= 30651.00 && inv_income <= 74200.99)
                taxRate = 0.25;
            else if (inv_income >= 74201.00 && inv_income <= 154800.99)
                taxRate = 0.28;
            else if (inv_income >= 154801.00 && inv_income <= 336550.99)
                taxRate = 0.33;
            else
                taxRate = 0.35;
            return taxRate;
        }
    }
}

As you can see from the above code, we've made significant improvements. The overall maximum cyclomatic complexity is reduced to 17. Maintenance should now be easier, and we've also fixed some of the problems discussed in the previous example. But still we are plagued by duplicate code in each class.

Interestingly, we have the beginnings of two design patterns here:

  • Strategy Pattern: Depending on how you implement the consumer of our investor hierarchy, we could end with the Strategy pattern. The intent of the Strategy pattern is to define a family of algorithms, encapsulate each one, and make them interchangeable. Strategy lets the algorithm vary independently from clients that use it.
  • Null Object Pattern: This pattern provides an object as a surrogate for the lack of an object of a given type. The Null Object provides intelligent do-nothing behavior, hiding the details from its collaborators.

So what are the issues with this solution?

The first issue I can think of is that the consumers of the Investor object now has the responsibility to instantiate the correct instance. Normally, you want to hide the creation of objects from the consumer, by using creational patterns to create your objects. Another issue is that we've only implemented three of the countries in the world, so if we had to implement half of all the countries, we are going to write a lot of duplicate code. This brings me quite nicely to the next evolution.

3. Evolution Two - Generalize

Moving our design along, we now generalize as much about the Investor implementation into an abstract base class. This helps us to reduce as much duplication as possible by inheriting the Investor base class.

What does an abstract base class offer us?

The abstract class provides abstract methods and properties that all derived classes that inherit from the abstract class must implement. Thus, when marking a class as abstract, it provides us a common definition of a base class that multiple derived classes can share. You can not instantiate an abstract class. If an abstract class inherits a virtual method from its base, the virtual method can be overridden with an abstract modifier. This virtual method is still virtual to any class inheriting from the abstract class.

The virtual keyword is used to modify a method, property, indexer, or event declaration, and allows it to be overridden in a derived class.

What is the difference between a virtual and abstract method?

A virtual method can have an implementation which you can decide to override in a derived class, if you wish to. abstract does not contain any implementation, and forces you to implement the method in derived classes.

So, looking at the diagram, you can see we moved all the duplicate properties into the Investor base class and declared the tax rate and tax liability properties as abstract. Now when implementing these properties in each derived class, we encapsulate that country's algorithm for calculating its tax rate and liability values.

Image 4

If you have a close look at the Investor base class you probably noticed the Create Investor Factory Method. Many people will call this a Creation Method. I agree that it is not a pure Factory Method as described by the Gang of Four.

Firstly, we will look at the abstract Investor object:

C#
public abstract class Investor : IInvestor
{
    private double income;
    protected Investor(double income)
    {
        this.income = income;
    }
    public static Investor CreateInvestor(double income)
    {
        ICurrentCultureInfo cultureInfo = new CurrentCultureInfo();
        return CreateInvestor(cultureInfo, income);
    }
    public static Investor CreateInvestor(ICurrentCultureInfo cultureInfo, 
                                          double income)
    {
        switch (cultureInfo.CurrentCultureName)
        {
            case "en-NZ":
                return new NewZealandInvestor(income);
            case "en-AU":
                return new AustralianInvestor(income);
            case "en-US":
                return new USAInvestor(income);
            default:
                return new NullInvestor(income);
        }
    }
    public double Income
    {
        get { return income; }
        set { income = value; }
    }
    public abstract double TaxLiability { get; }
    public abstract double TaxRate { get; }
}

Here is the implementation code for the USA investor:

C#
internal class USAInvestor : Investor
{
    private double taxRate;
    internal USAInvestor(double income) : base(income)
    {
    }
    public override double TaxRate
    {
        get
        {
            double inv_income = Income;
            if (inv_income >= 0.0 && inv_income <= 7550.99)
                taxRate = 0.10;
            else if (inv_income >= 7551.00 && inv_income <= 30650.99)
                taxRate = 0.15;
            else if (inv_income >= 30651.00 && inv_income <= 74200.99)
                taxRate = 0.25;
            else if (inv_income >= 74201.00 && inv_income <= 154800.99)
                taxRate = 0.28;
            else if (inv_income >= 154801.00 && inv_income <= 336550.99)
                taxRate = 0.33;
            else
                taxRate = 0.35;
            return taxRate;
        }
    }
    public override double TaxLiability
    {
        get
        {
            double taxLiability = 0.0;
            double inv_income = Income;
            if (inv_income > 7550.99)
            {
                taxLiability += 7550.99 * 0.10;
            }
            if (inv_income >= 0.0 && inv_income <= 7550.99)
            {
                taxLiability += inv_income * 0.10;
            }
            if (inv_income > 30650.99)
            {
                taxLiability += (30650.99 - 7551.00) * 0.15;
            }
            if (inv_income >= 7551.00 && inv_income <= 30650.99)
            {
                taxLiability += (inv_income - 7551.00) * 0.15;
            }
            if (inv_income > 74200.99)
            {
                taxLiability += (74200.99 - 30651.00) * 0.25;
            }
            if (inv_income >= 30651.00 && inv_income <= 74200.99)
            {
                taxLiability += (inv_income - 30651.00) * 0.25;
            }
            if (inv_income > 154800.99)
            {
                taxLiability += (154800.99 - 74201.00) * 0.28;
            }
            if (inv_income >= 74201.00 && inv_income <= 154800.99)
            {
                taxLiability += (inv_income - 74201.00) * 0.28;
            }
            if (inv_income > 336550.99)
            {
                taxLiability += (336550.99 - 154801.00) * 0.33;
            }
            if (inv_income >= 154801.00 && inv_income <= 336550.99)
            {
                taxLiability += (inv_income - 154801.00) * 0.33;
            }
            if (inv_income > 336551.00)
            {
                taxLiability += (inv_income - 336551.00) * 0.35;
            }
            return taxLiability;
        }
    }
}

At this point, it might be a good idea to replace the tax rate and liability private fields in NullInvestor with a symbolic constant. By doing this refactoring, you stop developers from being clever and using reflection to change the value of the private variable. That is not the main reason we want a constant. The constant helps in explaining why the variable is initialized to a zero value. It makes the code clearer.

4. Evolution Three - Responsibility-Driven Design

If we sit back and think about this problem for a few minutes, we realize that the responsibility for calculating the tax rate and tax liability is not the responsibility of the Investor. So what do we do now? We create an object or hierarchy of objects to describe and encapsulate the problem domain. This way of thinking about the design of software objects is called Responsibility-Driven Design (RDD).

In RDD, we think of software objects in terms of having responsibility, roles, and collaborations. Thus, we create an abstraction of what the objects do. The responsibilities can be categorized into two types that are related to the behavior of the object in terms of its roles:

  • Doing responsibilities of an object.
  • Knowing responsibilities of an object.

Responsibility is implemented by means of methods and properties that can collaborate with other methods or objects.

In the diagram below, you can see the new income tax engine concept.

Image 5

The following diagram shows the income tax engine hierarchy:

Image 6

In the Investor class, you can see how simple the code is now for the tax rate and liability properties:

C#
public class Investor : IInvestor
{
    private IIncomeTaxEngine taxEngine = IncomeTaxEngine.CreateInstance();
    ...
    // Code removed
    ...
    public double TaxLiability
    {
        get { return taxEngine.CalculateTaxLiability(Income); }
    }
    public double TaxRate
    {
        get { return taxEngine.CalculateTaxRate(Income); }
    }
}

Here is the sample code from the income tax engine hierarchy:

C#
public abstract class IncomeTaxEngine : IIncomeTaxEngine
{
    ...
    //Construction methods removed
    ...
    public abstract
            double CalculateTaxLiability(double income);
    public abstract
            double CalculateTaxRate(double income);
}
internal class UsaIncomeTaxEngine : IncomeTaxEngine
{
    public override double CalculateTaxRate(double income)
    {
        if (income >= 0.0 && income <= 7550.99) 
        ...
        //More of the algorithm
        ...
        else
            return 0.35;
    }
    public override double CalculateTaxLiability(double income)
    {
        double taxLiability = 0.0;
        if (income > 7550.99)
        {
            taxLiability += 7550.99 * 0.10;
        }
        ...
        //More of the algorithm
        ...
        return taxLiability;
    }
}

So how has our design changed since we applied Responsibility-Driven Design:

  1. The investor object has returned to its original representation with a new tax engine property. This property is of type IIncomeTaxEngine interface which means the tax engine object hierarchy is loosely coupled to the investor object. (Interesting question: What is the difference between loose and low coupling?)
  2. The tax engine object hierarchy is very cohesive in terms of its responsibility to calculate tax rates and tax liabilities. In computer programming, cohesion is a measure of how well the lines of source code within a module work together to provide a specific piece of functionality.
  3. The Investor object now delegates the responsibility to the tax engine hierarchy to calculate all tax related queries.

5. Evolution Four – Using C# 2.0 Features

With evolution four, we will utilize some of the new features present in C# 2.0. With the growing interest in dynamic languages, more people are running into a programming concept called Closures. C# 2.0 has true closure support in the form of anonymous delegates.

Image 7

So what is a Closure?

Martin Fowler describes a closure as follows:

Essentially, a closure is a block of code that can be passed as an argument to a function call. I'll illustrate this with a simple example. Imagine I have a list of employee objects and I want a list of those employees who are managers, which I determine with an IsManager property.

C#
public List<Employee> Managers(List<Employee> emps)
{
    return emps.FindAll(delegate(Employee e)
            {
                return e.IsManager;
            });
}

Wikipedia has more information on closures here.

To take advantage of closures in C#, we declare a new Calculation<T> delegate and modify the IIncomeTaxEngine interface method declarations to return our delegate.

C#
public delegate double Calculation<T>(T domainObject) where T : IInvestor;

public interface IIncomeTaxEngine
{
    Calculation<INVESTOR> CalculateTaxRate();
    Calculation<INVESTOR> CalculateTaxLiability();
}

The Investor invokes the calculate delegate methods and pass itself as a parameter:

public class Investor : IInvestor
{
    ...
    //More of the algorithm
    ...
    private IIncomeTaxEngine m_incomeTaxEngine = 
            IncomeTaxEngine.CreateInstance();
    public double TaxLiability
    {
        get { return m_incomeTaxEngine.CalculateTaxLiability().Invoke(this); }
    }
    public double TaxRate
    {
        get { return m_incomeTaxEngine.CalculateTaxRate().Invoke(this); }
    }
}

Here is the implementation for the USA income tax engine. Notice how we use the anonymous delegate in our implementation code.

C#
internal class UsaIncomeTaxEngine : IncomeTaxEngine
{
    ...
    // Code removed
    ...
    
    public override Calculation<INVESTOR> CalculateTaxRate()
    {
        return delegate(Investor investor)
               {
                   double income = investor.Income;
                   if (income >= 0.0 && income <= 7550.99)
                       return 0.10;
                   ...
                   // More of the algorithm
                   ...
                   else
                       return 0.35;
               };
    }
    public override Calculation<INVESTOR> CalculateTaxLiability()
    {
        return delegate(Investor investor)
               {
                   double taxLiability = 0.0;
                   double income = investor.Income;
                   if (income > 7550.99)
                   {
                       taxLiability += 7550.99 * 0.10;
                   }
                   ...
                   // More of the algorithm
                   ...
                   return taxLiability;
               };
    }
}

I have been utilizing anonymous delegates for a while now, and absolutely love them. But to be honest, does this evolution really add to our design? Do the closures add more clarity to our code, or do they make the code more complex? Looking at the metrics, we can see that average cyclomatic complexity and average depth, both, increased. I do think that using closures where appropriate is still feasible, but in this specific example, it does not lend itself well to the problem.

6. Evolution Five – More Responsibility-Driven Design

Returning now to the code, we ended with an evolution three. If we compare the algorithms in each of our country tax engines, we realize that the tax calculation for each country is exactly the same apart from the tax bands the algorithm operate on. At this point, we can abstract a domain object to describe this tax band concept.

Image 8

As you can see from the diagram above, we now have our tax band domain object. We also moved the country creation logic into a new tax band generator hierarchy. Guess what is the sole purpose of the tax band generator? Funny, you say. This is a very good example of high cohesion. The tax band generator hierarchy has one and only one purpose, to create a list of tax bands for the specific country.

Here is the code for the tax band:

C#
public class TaxBand
{
    private const double ZeroValue = 0.0;
    private double lowerLimitAmount;
    private double upperLimitAmount;
    private double taxRate;
    
    public TaxBand(double lowerLimitAmount, 
           double upperLimitAmount, double taxRate)
    {
        this.lowerLimitAmount = lowerLimitAmount;
        this.upperLimitAmount = upperLimitAmount;
        this.taxRate = taxRate;
    }
    
    public double CalculateTaxPortion(double income)
    {
        if (EqualToOrLessThan(income, LowerLimitAmount))
        {
            return ZeroValue;
        }
        else if (FallWithinTaxBand(income))
        {
            return (income - lowerLimitAmount) * taxRate;
        }
        else if (EqualToOrGreaterThan(income, UpperLimitAmount))
        {
            return (upperLimitAmount - lowerLimitAmount) * taxRate;
        }
        else
        {
            return ZeroValue;
        }
    }
    
    private bool FallWithinTaxBand(double income)
    {
        return EqualToOrGreaterThan(income, LowerLimitAmount) &&
               EqualToOrLessThan(income, UpperLimitAmount);
    }
    
    private bool EqualToOrGreaterThan(double income, double bandValue)
    {
        return income >= bandValue;
    }
    
    private bool EqualToOrLessThan(double income, double bandValue)
    {
        return income <= bandValue;
    }
    
    public double TaxRate
    {
        get { return taxRate; }
    }
    
    public double LowerLimitAmount
    {
        get { return lowerLimitAmount; }
    }
    
    public double UpperLimitAmount
    {
        get { return upperLimitAmount; }
    }
}

Here is the tax band generator hierarchy:

C#
public interface ITaxBandGenerator
{
    List<TaxBand> CreateTaxBands();
}

public abstract class TaxBandGenerator : ITaxBandGenerator
{
    public static ITaxBandGenerator CreateInstance()
    {
        return CreateInstance(new CurrentCultureInfo());
    }
    public static ITaxBandGenerator 
           CreateInstance(ICurrentCultureInfo cultureInfo)
    {
        switch (cultureInfo.CurrentCultureName)
        {
            case "en-NZ":
                return new NewZealandTaxBandGenerator();
            case "en-AU":
                return new AustralianTaxBandGenerator();
            case "en-US":
                return new UsaTaxBandGenerator();
            default:
                return new NullTaxBandGenerator();
        }
    }
    public abstract List<TaxBand> CreateTaxBands();
}

internal class UsaTaxBandGenerator : TaxBandGenerator
{
    public override List<TaxBand> CreateTaxBands()
    {
        List<TaxBand> taxBands = new List<TaxBand>();
        taxBands.Add(new TaxBand(0.0, 7550.99, 0.10));
        taxBands.Add(new TaxBand(7551.00, 30650.99, 0.15));
        taxBands.Add(new TaxBand(30651.00, 74200.99, 0.25));
        taxBands.Add(new TaxBand(74201.00, 154800.99, 0.28));
        taxBands.Add(new TaxBand(154801.00, 336550.99, 0.33));
        taxBands.Add(new TaxBand(336551.00, double.MaxValue, 0.35));
        return taxBands;
    }
}

I am only showing the USA tax band generator implementation here. It is interesting to note that the tax bands is now concentrated in one place. We could use many different methods at this point to populate the tax band list in the CreateTaxBand method,. e.g., config or database.

Image 9

Our income tax engine's responsibility is also been refined by the refactoring we have just done. When, for example, calculating the tax liability, the income of the investor is passed to the calculate tax liability method. This method iterates over the tax band collection and then delegates the responsibility to calculate the tax liability portion to the tax band instance. The interesting point here is that the order of the tax bands in the list does not matter.

C#
public class IncomeTaxEngine : IIncomeTaxEngine
{
    ...
    // More code
    ...
    
    public double CalculateTaxLiability(double income)
    {
        double taxLiability = ZeroValue;
        taxBands.ForEach(delegate(TaxBand taxBand)
        {
            taxLiability += taxBand.CalculateTaxPortion(income);
        });
        return taxLiability;
    }
    public double CalculateTaxRate(double income)
    {
        TaxBand selectedBand = taxBands.Find(delegate(TaxBand taxBand)
        {
            bool incomeWithinBands = (income >= taxBand.LowerLimitAmount &&
                income <= taxBand.UpperLimitAmount);
            return incomeWithinBands;
        });
        return selectedBand.TaxRate;
    }
}

As you can see from the above code, we've made significant improvements. The overall maximum cyclomatic complexity is reduced to 5. Maintenance should now be a breeze. We have also distilled the responsibility of each object to the bare minimum.

Interestingly, if you inspect the code, you will find that I do not check for null anywhere. This is probably the biggest advantage that we gain from the Null Object Pattern.

Points of Interest

Due to Test-Driving the code in each evolution, the design of the code is slightly different from the code someone would have written if he did not use Test-Driven Development.

Why do I make this statement?

Well, the easiest example is in the Investor object of the procedural way example. If we have a look at the code, you will notice the CurrentCultureName property. I do not directly switch on the static CultureInfo.CurrentCulture.Name property of the .NET framework, but rather encapsulate the value returned by CultureInfo.CurrentCulture.Name in a property. By doing this, I now have the ability to specify which country I want to target when running the unit tests.

History

  • 21 November 2006 - Original posting.

License

This article, along with any associated source code and files, is licensed under The MIT License