(NOTE: This post examines a only few principles, and addresses them in isolation. Learning is a stepwise process, and I want to cater to that.)
Developers everywhere want code that is easy to understand, and easy to change to take new requirements into account. Usually, code starts out simple, but over time, as developers add new rules and special exceptions, code can become confusing. Over time the code may reach a point where maintainers are afraid to go into sections of the code because they're only guessing as to what things to, and unsure what a change could break in other parts of the system. Take a look at this class, which exhibits some of the typical problems:
Public Class BadClass
Private ord As Order
Public Sub New(ByVal inOrder as Order)
ord = inOrder
End Sub
Public Function CalculateOrderTotal() As Decimal
' this first part adds up the cost of all the products
Dim total As Decimal = 0D
For Each prod In ord.Products
If ord.OrderedBy.CustomerLevel = CustomerLevel.Gold Then
If prod.ID = 745 Or prod.ID = 265 Then
total += 11.5D
Else
total += prod.Price * 0.8
End If
ElseIf ord.OrderedBy.CustomerLevel = CustomerLevel.Silver Then
If prod.ID = 745 Or prod.ID = 265 Then
total += prod.Price * 0.7 Else
total += prod.Price * 0.9
End If
Else
If prod.ID = 745 Or prod.ID = 265 Then
total += prod.Price * 0.9
Else
If DateTime.Now > New DateTime(2009, 1, 18) AndAlso DateTime.Now < New DateTime(2009, 2, 18) Then
total += prod.Price - 3
Else
total += prod.Price
End If
End If
End If
Next
' this next section adds in the shipping
If ord.OrderedBy.CustomerLevel = CustomerLevel.Gold Then
Return total
ElseIf ord.OrderedBy.CustomerLevel = CustomerLevel.Silver Then
Return total + 3.5D
Else
If total < 20 Then
Return total + 1.4D
ElseIf total < 40 Then
Return total + 2.5D
ElseIf total < 70 Then
Return total + 5D
Else
Return total + 8D
End If
End If
End Function
End Class
So, why these 745 and 265 numbers ? They look like product Ids. What if a new product needs to be added? Also, the lower section appears to calculate shipping, but what if we want to calculate shipping elsewhere? What if we have a new kind of CustomerLevel? This class looks complicated, and I'm a little worried that if I make changes, I could break something. If we continue adding features to this class as it is, we'll only be making things scarier for next time. When we mess with one section of this to add a new feature, we're risking code that's already live, so that we can't have confidence that it still works.
Let's take a first hack at fixing this:
Public Class BetterClass
Private ord As Order
Public Sub New(ByVal inOrder as Order)
ord = inOrder
End Sub
Public Function CalculateOrderTotal() As Decimal
' this first part adds up the cost of all the products
Dim total As Decimal = 0D
For Each prod In ord.Products
If ord.OrderedBy.CustomerLevel = CustomerLevel.Gold Then
If ProductIsMegaDeal(prod) Then
total += 11.5D
Else
total += prod.Price * 0.8
End If
ElseIf ord.OrderedBy.CustomerLevel = CustomerLevel.Silver Then
If ProductIsMegaDeal(prod) Then
total += prod.Price * 0.7
Else
total += prod.Price * 0.9
End If
Else
If ProductIsMegaDeal(prod) Then
total += prod.Price * 0.9
Else
If IsDealDays() Then
total += prod.Price - 3
Else
total += prod.Price
End If
End If
End If
Next
Return total + CalculateShipping(ord, total)
End Function
Public Function CalculateShipping(ByVal total As Decimal) As Decimal
' this next section adds in the shipping
If ord.OrderedBy.CustomerLevel = CustomerLevel.Gold Then
Return 0
ElseIf ord.OrderedBy.CustomerLevel = CustomerLevel.Silver Then
Return 3.5D
Else
If total < 20 Then
Return 1.4D
ElseIf total < 40 Then
Return 2.5D
ElseIf total < 70 Then
Return 5D
Else
Return 8D
End If
End If
End Function
Public Function ProductIsMegaDeal(ByVal prod As Product) As Boolean
If prod.ID = 745 Or prod.ID = 265 Then
Return True
End If
Return False
End Function
Public Function IsDealDays() As Boolean
Return DateTime.Now > New DateTime(2009, 1, 18) AndAlso DateTime.Now < New DateTime(2009, 2, 18)
End Function
End Class
So, phase one, we've grouped this up into a lot of different methods in our class. This is already a lot better-at least I can see what's going on. However, as all these methods are in the same class, interacting together all over the place, we still have a potential problem. These methods, which might be used in different contexts for different purposes throughout the system are all in the same class, where they might share private variables.(In fact, they share "ord") If one method is called, it could unexpectedly affect the way other methods act in different circumstances. The methods may even act differently depending on the order they are called, which could make behavior more unexpected. It's definitely easier to read than before, but because of this entanglement, there's still a concern that changes could break things you never expected, and never intended.
So, how can we lower the scare factor here? The Single Responsibility Principle (http://www.objectmentor.com/resources/articles/srp.pdf) offers us a way to help clean things up. This principle states "THERE SHOULD NEVER BE MORE THAN ONE REASON FOR A
CLASS TO CHANGE." We can see that in the code above, there are lots of reasons for this class to change, so when we work in one method, its interactions with private methods and variables could break other unrelated methods in the same class... and so other, should-be-unaffected methods/features are at risk of breaking. As these methods are all together and all bound up together, NONE of them may ever stay in a state where we leave them alone for long periods of time, tested and stable... instead, we'll keep changing the class, and as we do so, we're introducing risk and possible confusion into things that have nothing to do with the tweak of the day. SRP can help lower this risk, but what would putting SRP into this code mean? Let's have a go at chopping this up, keeping SRP in mind:
Public Class OrderTotalService
Public Function CalculateOrderTotal(ByVal ord As Order) As Decimal
' this first part adds up the cost of all the products
Dim total As Decimal = 0D
For Each prod In ord.Products
If ord.OrderedBy.CustomerLevel = CustomerLevel.Gold Then
If ProductIsMegaDeal(prod) Then
total += 11.5D
Else
total += prod.Price * 0.8
End If
ElseIf ord.OrderedBy.CustomerLevel = CustomerLevel.Silver Then
If ProductIsMegaDeal(prod) Then
total += prod.Price * 0.7
Else
total += prod.Price * 0.9
End If
Else
If ProductIsMegaDeal(prod) Then
total += prod.Price * 0.9
Else
Dim ddService = New DealDayService()
If ddService.IsDealDays() Then
total += prod.Price - 3
Else
total += prod.Price
End If
End If
End If
Next
Dim shipService As New ShippingService
Return total + shipService.CalculateShipping(ord, total)
End Function
Public Function ProductIsMegaDeal(ByVal prod As Product) As Boolean
If prod.ID = 745 Or prod.ID = 265 Then
Return True
End If
Return False
End Function
End Class
Public Class DealDayService
Public Function IsDealDays() As Boolean
Return DateTime.Now > New DateTime(2009, 1, 18) AndAlso DateTime.Now < New DateTime(2009, 2, 18)
End Function
End Class
Public Class ShippingService
Public Function CalculateShipping(ByVal ord As Order, ByVal total As Decimal) As Decimal
' this next section adds in the shipping
If ord.OrderedBy.CustomerLevel = CustomerLevel.Gold Then
Return 0
ElseIf ord.OrderedBy.CustomerLevel = CustomerLevel.Silver Then
Return 3.5D
Else
If total < 20 Then
Return 1.4D
ElseIf total < 40 Then
Return 2.5D
ElseIf total < 70 Then
Return 5D
Else
Return 8D
End If
End If
End Function
End Class
That's getting better- the shipping code is now completely independent of the OrderTotal Code, and the DealDay calculation, which could also be used in all kinds of situations having nothing to do with totaling orders is also pulled out. These classes now are definitely unaffected by any changes in the CalculateOrderTotal function. As the classes are physically broken apart and separated, they can't affect each other. Noteworthy is the MegaDeal code, which didn't seem to be an independent service-level calculation, but it doesn't seem to belong where it still is. Also, every time we need to change the actions of a CustomerLevel, or add a new CustomerLevel, we're going to have to make changes to this file... and each time we touch this file, we could break things for existing customer types. If the company decides they want a lot of customer levels, this file could get very big and hard to debug. Also of note is that the OrderTotalService only seems to be able to run against one shipping calculation tool-if, let's say we end up with multiple shipping vendors in the future that require us to have different shipping calculators, we're going to go back and change this code. Testing looks like it could be complicated as well. I'll examine those issues and how to address them in future parts of this series.
Posted
01-20-2009 7:52 PM
by
Anne Epstein