In Progress
Unit 1, Lesson 1
In Progress

Microconcerns

Video transcript & code

Separation of concerns is something we usually strive for in our programs. We try to keep different concerns separate at the project level using libraries or service-oriented architecture; at the program level using modules and classes; and at the class level using methods. But today I want to talk about separating concerns at a really low level: the level of individual lines.

The example I'm going to use today is loosely based on part of the "Gilded Rose" code kata. If you've never gone through this kata before on your own, you might want to go look it up and work through it before continuing. It's a great programming exercise, and it's worthwhile to tackle it without any prior solution bias this video might give you.

Continuing on…

Consider a class representing a cheese on a shelf.

class Cheese
  attr_reader :value

  def initialize(value)
    @value = value
  end
end

Cheese has value. And since this is the sort of cheese that adds value the longer it ages, we have an #age method which increments its value.

class Cheese
  attr_reader :value

  def initialize(value)
    @value = value
  end

  def age
    @value += 1
  end
end

However, there is a ceiling to what people will pay for cheese. So the value of cheese is capped at 50.

To model this, we make the increment conditional on whether the current value is less than 50.

class Cheese
  attr_reader :value

  def initialize(value)
    @value = value
  end

  def age
    @value += 1 if value < 50
  end
end

Let's just play with this for a moment.

We'll make a cheese with a value of 48.

When we age it once, it gains a value of 49.

When we age it again, the value goes up to 50.

But when we age it a third time, the value stays at 50.

c = Cheese.new(48)
c.age
c.value                         # => 49
c.age
c.value                         # => 50
c.age
c.value                         # => 50

So far so good. But as we flesh out our requirements, we find we have to deal with more than one kind of cheese. Green cheese from the moon gains 2 points of value for every unit of aging.

We deal with this complication by first encapsulating the increment unit into a separate, private method.

This then enables us to inherit a GreenCheese class, with an overridden increment value of 2.

class Cheese
  attr_reader :value

  def initialize(value)
    @value = value
  end

  def age
    @value += increment if value < 50
  end

  private

  def increment
    1
  end
end

class GreenCheese < Cheese
  private
  def increment
    2
  end
end

If we instantiate a GreenCheese object with a value of 48 and age it once, we get a value of 50.

If we age it again, the value is still 50.

gc = GreenCheese.new(48)
gc.age
gc.value                        # => 50
gc.age
gc.value                        # => 50

But what if we instead start the cheese at value 49.

When we age it once, the resulting value is 51!

class GreenCheese < Cheese
  private
  def increment
    2
  end
end

gc = GreenCheese.new(49)
gc.age
gc.value                        # => 51

This violates the constraint that the value will always be capped at 50.

This happened because 49 is less than 50, and so our code permitted the increment.

To fix this, we update our original #age method to increment the value by the either the increment unit, or the difference between the current value and 50, whichever is smaller.

class Cheese
  attr_reader :value

  def initialize(value)
    @value = value
  end

  def age
    @value += [increment, 50-value].min if value < 50
  end

  private

  def increment
    1
  end
end

Now when we age a value-49 green cheese, its value is correctly held down to 50.

gc = GreenCheese.new(49)
gc.age
gc.value                        # => 50
gc.age
gc.value                        # => 50

This code works, but the algorithm is getting kind of awful. Especially for a one-liner.

We could extract out the calculation of an adjusted increment value into a separate variable.

def age
  adjusted_increment = [increment, 50-value].min
  @value += adjusted_increment if value < 50
end

But that doesn't really get at the root of the problem. The real issue here is that the line which increments the value has acquired two responsibilities.

The first responsibility is incrementing value. The second responsibility is constraining the value.

And it may not be immediately obvious, but after factoring out the adjusted_increment variable, we've actually forced both of those responsibilities onto this new extracted line as well!

After all, this line is concerned with both an increment, and the limits imposed on that increment by the overall value cap. This is an interesting case of a refactoring that doesn't actually separate any concerns.

It may seem strange to talk about separation of concerns between individual lines in a method. But I believe that this is where good encapsulation and cohesion begins. To have well-factored, composable methods, objects, and modules, we need to start with good separation of concerns at the lowest and smallest of levels.

Let's undo that change.

def age
  @value += [increment, 50-value].min if value < 50
end

Now, how can we refactor this in a way that separates concerns?

Here's one way. On line one, we can increment.

Then on line two, we can limit, using the lesser of the current value of the value cap.

def age
  @value += increment
  @value = [@value, 50].min
end

These lines have total separation of concerns. Line one only knows about incrementing value, and doesn't know anything about constraining the value. Line two knows only how to constrain the value, and doesn't care at all about increments or anything else we might have done on the lines before it. These are two self-contained operations.

In fact, they are so neatly self contained that we could easily factor them out into their own meaningfully-named private methods, leaving the original #age method as what Kent Beck refers to as a composed method.

Now we have one method concerned with incrementing, one concerned with capping, and one method whose only job is to make sure those other operations happen in the right order.

class Cheese
  attr_reader :value

  def initialize(value)
    @value = value
  end

  def age
    increment_value
    constrain_value
  end

  private

  def increment_value
    @value += increment
  end

  def constrain_value
    @value = [@value, 50].min
  end

  def increment
    1
  end
end

This is a neat, elegant separation of concerns, and I really like how it has come out.

Although, if you're anything like me, you might at the same time be feeling like there's something very wrong with this solution. It's a feeling that stems from the possibility that for a brief moment between the increment_value and constrain_value steps, this object may contain invalid instance data.

That's a legitimate worry, and we'll talk about it at length in the next episode. Until then: happy hacking!

Responses