Last Update: July 4, 2017
Today, I’m pairing, but not the way you might expect. I’m pairing craft beer with craft code. The beer is Ithaca Brewing Company’s Creeker - a juicy, malty, bready double IPA that clocks in at 8.5% ABV and reminds me of a stronger version of Heavy Seas’s Loose Cannon. It smells great!
The code does not smell great. It’s an example of primitive obsession, which is, ironically, not a cologne. It’s a code smell that arises when primitives (e.g. strings, integers, etc.) are used in place of objects.
Let’s say we have a person class of which instances have a name and a phone number.
class Person
attr_reader :name, :phone_number
def initialize(name:, phone_number:)
@name, @phone_number = name, phone_number
end
end
person = Person.new(name: 'Ford', phone_number: '210-690-4242')
person.name #=> Ford
person.phone_number #=> 210-690-4242
Now, let’s add a new requirement. We want to know if a person has a valid phone number. We can accomplish this by adding a method that checks if the phone number matches a common format.
class Person
# ... code omitted ...
def valid_phone_number?
patterns = [
/^\d{10}$/, # XXXXXXXXXX
/^\d{3}-\d{3}-\d{4}$/, # XXX-XXX-XXXX
/^\d{3}\.\d{3}\.\d{4}$/, # XXX.XXX.XXXX
/^\(\d{3}\)\s{0,1}\d{3}-\d{4}$/, # (XXX)XXX-XXXX, (XXX) XXX-XXXX
]
patterns.any? { |p| p.match? phone_number.to_s }
end
end
ford = Person.new(name: 'Ford', phone_number: '210-690-4242')
ford.valid_phone_number? #=> true
arthur = Person.new(name: 'Arthur', phone_number: '555-1212')
arthur.valid_phone_number? #=> false
This works, and the person class is still small. However, the person class arguably should not know how to validate a phone number. The person class should only be responsible for things directly related to a person. What if we want to convert the phone number to different formats? Should the person class hold those methods? By using a primitive (i.e. a string) to store the phone number, we’re left with no good place to put the responsibilities related to working with phone numbers. This is a case of primitive obsession.
To fix it, let’s create a new abstraction - a separate phone number class. We can move the phone number validation code there.
class PhoneNumber
def initialize(value)
@value = value
end
def valid?
patterns = [
/^\d{10}$/, # XXXXXXXXXX
/^\d{3}-\d{3}-\d{4}$/, # XXX-XXX-XXXX
/^\d{3}\.\d{3}\.\d{4}$/, # XXX.XXX.XXXX
/^\(\d{3}\)\s{0,1}\d{3}-\d{4}$/, # (XXX)XXX-XXXX, (XXX) XXX-XXXX
]
patterns.any? { |p| p.match? @value.to_s }
end
end
phone_number = PhoneNumber.new('123-123-1234')
phone_number.valid? #=> true
We can remove the validation code from the person class and delegate the validity check to the phone number.
class Person
attr_reader :name, :phone_number
def initialize(name:, phone_number:)
@name, @phone_number = name, phone_number
end
def valid_phone_number?
phone_number.valid?
end
end
Now, when we create people, we can give them phone number objects instead of strings.
ford = Person.new(name: 'Ford', phone_number: PhoneNumber.new('210-690-4242'))
p ford.valid_phone_number? #=> true
arthur = Person.new(name: 'Arthur', phone_number: PhoneNumber.new('foo'))
p arthur.valid_phone_number? #=> false
Being able to identify and alleviate code smells is one of the most important tools in your refactoring toolkit. If you are not familiar with the topic of code smells and/or refactoring, Source Making is a great reference. Cheers!