Principle
A class, properties and methods should be open for extension but closed for modification. E.g. If we need to add new functionality to a class, it should be added using a derived class.
Scenario
To demonstrate OCP I will continue using the E-Commerce example from the previous blog. A customer has an order which includes multiple order items. Given an order we calculate the total cost and the tax due based on US state.
Before Code
We will use the code from the previous blog which has the separate Tax class. The problem we have with the Tax class is that it is not closed for modification. If we have to add a new state, we need to edit the CalculateTax method to include the new state in the if condition.
public class Customer
{
public string StateCode;
public string ZipCode;
public string County;
}
public class Order
{
public List<OrderItem> _orderItems = new List<OrderItem>();
public decimal CalculateTotal(Customer customer)
{
decimal total = _orderItems.Sum((item) =>
{
return item.Cost * item.Quantity;
});
Tax t = new Tax();
total = total + t.CalculateTax(customer.StateCode, total);
return total;
}
}
public class OrderItem
{
public int Quantity;
public string Code;
public decimal Cost;
}
public class Tax
{
public decimal CalculateTax(string StateCode, decimal Total)
{
decimal tax;
if (StateCode == "TX")
tax = Total * .08m;
else if (StateCode == "FL")
tax = Total * .09m;
else
tax = .03m;
return tax;
}
}
After Code
The answer to this problem can be solved with defining an Interface ITax with a CalculateTax method and then create one class for each US state. Let’s try that.
public class Customer { public string StateCode; public string ZipCode; public string County; } public class Order { public List<OrderItem> _orderItems = new List<OrderItem> public decimal CalculateTotal(Customer customer) { decimal total = _orderItems.Sum((item) => { return item.Cost * item.Quantity; }); if (customer.StateCode == "TX") total += new TXTax().CalculateTax(total); else if (customer.StateCode == "FL") total += new FLTax().CalculateTax(total); else total += new NULLTax().CalculateTax(total); return total; } } public class OrderItem { public int Quantity; public string Code; public decimal Cost; } public interface ITax { decimal CalculateTax(decimal Total); } public class TXTax : ITax { public decimal CalculateTax(decimal Total) { return Total * .08m; } } public class FLTax : ITax { public decimal CalculateTax(decimal Total) { return Total * .09m; } } public class NULLTax : ITax { public decimal CalculateTax(decimal Total) { return .03m; } }
Whilst this works we have reintroduced another problem. We have moved our OCP problem from the Tax class into the Order class. We can solve this issue by using a Factory design pattern.
public interface ITaxFactory { ITax GetTaxObject(string StateCode); } public class TaxFactory : ITaxFactory { public ITax GetTaxObject(string StateCode) { Assembly currentAssembly = Assembly.GetExecutingAssembly(); var TaxFactoryType = typeof(TaxFactory); var currentType = currentAssembly.GetTypes().SingleOrDefault(t => t.FullName == (TaxFactoryType.Namespace + "." + StateCode + "Tax")); if (currentType != null) return (ITax)Activator.CreateInstance(currentType); return new NULLTax(); } }
The factory is used for locating the correct Tax class based on the State. Using reflection we can locate the type within the assembly. We can then use the factory to return the Tax class for calculating the order total.
public decimal CalculateTotal(Customer customer)
{
decimal total = _orderItems.Sum((item) =>
{
return item.Cost * item.Quantity;
});
ITax tax = new TaxFactory().GetTaxObject(customer.StateCode);
total += tax.CalculateTax(total);
return total;
}
We can run the before and after tests and it will still work fine.
Sidenote
The code so far actually does not conform to the full SOLID principles however; we will discuss this in the up and coming blogs.