(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:
view plaincopy to clipboardprint?
- 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:
view plaincopy to clipboardprint?
- 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:
view plaincopy to clipboardprint?
- 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.
Leave a Reply