Nate Davis Olds

Ideas. Presentations. Showcases.

Things That Have Changed the Way I Code

| Comments

If I were to graph my programming skills over time, I think it would look something like this.

Imagined Programmer Skill Graph

Every once in a while, I experience a shift in the way I approach writing code. These AH-HA explosions are triggered by moments of clarity followed by a rapid transformation of my programming practice. It is my theory that the frequency of these AH-HA moments is a more accurate measurement of coding skill than the amount of time spent working as a programmer.

I’d like to share one such AH-HA moment I’ve had in the last few years.

Represent key concepts with domain objects instead of core data types.

When coding, I’ve shifted from querying core data types, like String, Array, or Hash, in favor of creating small domain objects. This practice aids the conceptual understanding of the problem; making reading code, changing code, testing code, and improving code efficiency easier. Let me show you what I mean.

Here’s a code representation of our example business problem.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
# Anyone has income less than $1000
class OfferingOne
  def self.eligible? household
    household.people.any? do |person|
      countable_incomes = household.incomes.select { |i| i.person == person }
      countable_incomes.map(&:amount).sum < 1000
    end
  end
end

# Someone has pension that is less than 300
class OfferingTwo
  def self.eligible? household
    household.people.any? do |person|
      countable_incomes = household.incomes.select do |i|
         i.person == person &&
           i.income_type_key == 'pension'
      end

      !countable_incomes.empty? && countable_incomes.map(&:amount).sum < 300
    end
  end
end

# Total earned income < 1500
class OfferingThree
  def self.eligible? household
    countable_incomes = household.incomes.select do |i|
       i.categories.include?('earned')
    end

    countable_incomes.map(&:amount).sum < 1500
  end
end

There are three offerings in which we need to find out if a household is eligible. Each offering has a different set of criteria. Offering one iterates through each household person, gathers up the countable_incomes for that person, and then compares the sum to under $1000. Offering Two is similar. It also iterates over each person but then isolates on pension income. Finally, it determines eligibility by checking the presence of pension income below our limit. Finally, offering three collects all household earned income. Then compares the total to the limit of $1500.

As you might imagine the key object here is the Income class.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
class Income
  attr_reader :amount, :income_type_key, :person

  def initialize options={}
    @amount = options.fetch(:amount, 0.0)
    @person = options.fetch(:person) { Person::Nobody.new }
    @income_type_key = options[:income_type_key]
  end

  def categories
    case income_type_key
    when "wage"
      ["earned"]
    when "social_security"
      ["unearned","government"]
    when "pension"
      ["unearned"]
    else
      []
    end
  end
end

An income is represented with an amount, an income_type_key, and a person. All these attributes are passed in a familiar fashion.

Categories holds a logical mapping of income_type_key to a list of categories. As we add various types of income we will need to update this map to make sure our income has the correct categories.

Another way to represent categories is through a hash.

1
2
3
4
5
6
7
8
9
  def categories
    category_map = {
      "wage" => ["earned"],
      "social_security" => ["unearned","government"],
      "pension" => ["unearned"]
    }

    category_map[income_type_key] || []
  end

I suspect that this code seems familiar to you. I know I write code like this all the time. And there is nothing wrong with it. It will function exactly as expected. However, in dynamic applications where we have a long-term commitment to the code, we owe it to our team and to ourselves to write maintainable code.

Whether it be with a hash or a case statement, when I see this style of code I know it is hiding another object. To get it out, use composition.

Composition is a style of coding which combines simple objects into more complex ones. In this case, Income has the smaller IncomeType inside it.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
class IncomeType
  def categories
    []
  end
end

class WagesIncomeType < IncomeType
  def categories
    ["earned"]
  end
end

class SocialSecurityIncomeType < IncomeType
  def categories
    ["unearned", "government"]
  end
end

class PensionIncomeType < IncomeType
  def categories
    ["unearned"]
  end
end

If we create a IncomeType class with a #categories method we can then create a class for every type of income we want to represent. We can clearly see and reason about the properties of each type.

Back in our Income class, we create a private method of income_type.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
class Income
  attr_reader :amount, :income_type_key, :person

  def initialize options={}
    @amount = options.fetch(:amount, 0.0)
    @person = options.fetch(:person) { Nobody.new }
    @income_type_key = options[:income_type_key]
  end

  def categories
    income_type.categories
  end

  private

  def income_type
    @income_type ||= begin
      "#{income_type_key}IncomeType".constantize.new
    rescue
      IncomeType.new
    end
  end
end

It tries to find a class based on the income_type_key otherwise it creates a blank income type. Now, in the categories method it is a simple call to the income_type to get the categories. Adding an income type is as simple as adding this value class.

In this way we’ve removed the comparison to strings or hashes and instead have conceptual domain object (IncomeType) to provide the information we need.

Back to Offerings

There are other decisions being made through comparisons.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
# Anyone has income less than $1000
class OfferingOne
  def self.eligible? household
    household.people.any? do |person|
      countable_incomes = household.incomes.select { |i| i.person == person }
      countable_incomes.map(&:amount).sum < 1000
    end
  end
end

# Someone has pension that is less than 300
class OfferingTwo
  def self.eligible? household
    household.people.any? do |person|
      countable_incomes = household.incomes.select do |i|
         i.person == person &&
           i.income_type_key == 'pension'
      end

      !countable_incomes.empty? && countable_incomes.map(&:amount).sum < 300
    end
  end
end

# Total earned income < 1500
class OfferingThree
  def self.eligible? household
    countable_incomes = household.incomes.select do |i|
       i.categories.include?('earned')
    end

    countable_incomes.map(&:amount).sum < 1500
  end
end

The comparisons i.person == person, i.income_type_key == 'pension', and i.categories.include?('earned') are classic examples of feature envy. Feature envy is when a method is more interested in a class other than the one that it is in. In these cases we are querying information from income then comparing to something else. Shouldn’t Income be the class that knows if it for a person? or is an income type? Or has a category? If we let this continue, these logical comparisons will litter our code. If any of these comparisons change, we would have to find all the places in our code we compare and then change those. That isn’t something I want to maintain long term.

To solve it, the comparison is moved into Income.

1
2
3
4
5
class Income
  def person? candidate
    person == candidate
  end
end

Now the offerings can ask income if it is for a person.

1
countable_incomes = household.incomes.select { |i| i.person? person }

and

1
2
3
4
countable_incomes = household.incomes.select do |i|
   i.person?(person) &&
     i.income_type_key == 'pension'
end

Income should also know which category it is in and if it ‘is’ as certain type. However now with our composition changes the IncomeType is what should know if it is of a type. So, let’s forward the decision to income type.

1
2
3
4
5
6
7
  def category? candidate
    income_type.category? candidate
  end

  def is? candidate
    income_type.is? candidate
  end

Moving categories and is? to IncomeType, the result looks like this:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
class IncomeType
  def category? candidate
    categories.include? candidate
  end

  def categories
    []
  end

  def key
    ""
  end

  def is? candidate
    key == candidate.to_s
  end
end

class WagesIncomeType < IncomeType
  def key
    "wages"
  end

  def categories
    ["earned"]
  end
end

class SocialSecurityIncomeType < IncomeType
  def key
    "social_security"
  end

  def categories
    ["unearned", "government"]
  end
end

class PensionIncomeType < IncomeType
  def key
    "pension"
  end

  def categories
    ["unearned"]
  end
end

Now, our offerings ask the income if it is of a type or category or for a person.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
# any person in the household makes less than a 1000 a month
class OfferingOne
  def self.eligible? household
    household.people.any? do |person|
      countable_incomes = household.incomes.select { |i| i.person? person }
      countable_incomes.map(&:amount).sum < 1000
    end
  end
end

# pension and is less than 300
class OfferingTwo
  def self.eligible? household
    household.people.any? do |person|
      countable_incomes = household.incomes.select { |i| i.person?(person) && i.is?('pension') }
      !countable_incomes.empty? && countable_incomes.map(&:amount).sum < 300
    end
  end
end

# total earned income < 1500
class OfferingThree
  def self.eligible? household
    countable_incomes = household.incomes.select { |i| i.category? 'earned' }
    countable_incomes.map(&:amount).sum < 1500
  end
end

The advantage comes when there are changes to the business rules we are building this code on. Especially when adding a new income type or when an income type changes its category. We don’t have to touch the logic to introduce another income type to the configuration.

The code is cleaner, but there is one piece that I would like to take bit further. The approach of adding classes that represents each type bothers me. Aren’t they all instances of the IncomeType class instead of classes of their own? So, I’ve written a gem to help with this problem. I call it identitee.

With identitee, we can identify a code block with a key. The block is executed to build an object (in this case IncomeType). Later, this same object can be retrieved through its Identitee key and be used in our code.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
require 'identitee'

class IncomeType
  include Identitee

  def self.find_or_none income_type_key
    type = find income_type_key
    type == 'Unknown' ? UnknownIncomeType.new : type
  end

  def is? candidate
    key == candidate.to_s
  end

  def category? candidate
    categories.include? candidate.to_s
  end

  def categories
    @categories ||= []
  end

  def categorized *categories_to_add
    @categories = [*categories_to_add].map(&:to_s)
  end

  class UnknownIncomeType
    def key; "unknown"; end
    def category?(candidate); false; end
    def categories; []; end
    def is?(candidate); false; end
  end
end

Now in the income_types, folder we can write definitions for each income type.

incomes_types/wage.rb

1
2
3
IncomeType.identify "wage" do
  categorized "earned"
end

income_types/social_security.rb

1
2
3
IncomeType.identify "social_security" do
  categorized "unearned", "government"
end

income_types/pension.rb

1
2
3
IncomeType.identify "pension" do
  categorized "unearned"
end

In income, we now can find an income type by key. Income has be become a value object. Easy to maintain.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
require 'income_type'
require 'person'

class Income
  extend Forwardable

  def_delegators :income_type, :category?, :is?
  attr_reader :amount, :person

  def initialize options={}
    @amount = options.fetch(:amount, 0.0)
    @person = options.fetch(:person) { Person::Nobody.new }
    @income_type_key = options[:income_type_key]
  end

  private

  attr_reader :income_type_key

  def income_type
    @income_type ||= IncomeType.find_or_none income_type_key
  end
end

Back (again) to Offerings

Now let’s look back at our offerings. We are working a lot with arrays of Incomes. We filter by category and by type. We group by people. We total all incomes. There is a bunch repeated logic that would be best to standardize. We can do that by using Enumerable.

Enumerable is a mix-in providing collection classes with several traversal and searching methods, and with the ability to sort. Enumerable methods are familiar methods like map, select, inject, and each. Let’s start by creating an Incomes class and getting Enumerable to work.

1
2
3
4
5
6
7
8
9
10
11
12
13
class Incomes
  include Enumerable

  def initialize incomes=[]
    @incomes = [*incomes]
  end

  def each &block
    incomes.each &block
  end

  attr_reader :incomes
end

This gives us the standard methods but let’s create some that filter our list of incomes.

1
2
3
4
5
6
7
8
9
  def categories *candidates
    new_for select { |income| income.category? candidates }
  end

  private

  def new_for ary
    self.class.new ary
  end

Let’s add a filter for types

1
2
3
  def types *candidates
    new_for select { |income| income.is? candidates }
  end

Let’s add group by person.

1
2
3
4
5
  def group_by_person
    group_by(&:person).collect do |person, person_incomes|
      new_for person_incomes
    end
  end

Finally, totaling the incomes.

1
2
3
  def total
    map(&:amount).reduce(&:+)
  end

Going back to the offerings we can use our filters to make eligibility methods very readable.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
  # any person in the household makes less than a 1000 a month
  class OfferingOne
    def self.eligible? household
      household.incomes.group_by_person.any? do |incomes|
        incomes.total < 1000
      end
    end
  end

  # pension and is less than 300
  class OfferingTwo
    def self.eligible? household
      incomes.types('pension').group_by_person.any? do |incomes|
        incomes.receiving? && incomes.total < 300
      end
    end
  end

  # total earned income < 1500
  class OfferingThree
    def self.eligible? household
      incomes.categories('earned').total < 1500
    end
  end

The only comparison is the one needed for the eligibility itself. this comparison is exactly in the correct place.

Take away

Representing key concepts with objects instead of core data types makes code easier to maintain. This is essential for long term relationships with complex problems.

Eliminate Feature Envy. Breakdown into domain objects using composition. Encapsultate collections using Enumerable.

I hope this also changes the way you code.

Comments