Abstract vs. DRY

DRY is the worst programming advice ever. It may seem easy. But, it makes changing or extending software harder. Abstraction, on the other hand, may seem difficult, but makes changing and extending software simpler. Prefer simple over easy.
Abstract vs. DRY
Photo by Wietse Jongsma / Unsplash

We've talked about this before, including on our podcast and blog, but it bears repeating: DRY is the worst programming advice ever. It will almost always make your code harder to change as requirements evolve.

For example, say you have some code that uses a driver to manipulate a vending machine to prepare either coffee or tea. Like this:

class VendingMachine
  attr_reader :driver

  def initialize(driver: Driver.new)
    @driver = driver
  end

  def vend(beverage:, options: {})
    case beverage
    when :coffee
      dispense_cup 
      heat_water 
      prepare_grounds 
      dispense_water
      dispense_sweetener if options[:sweet]
      dispense_cream if options[:creamy]
      dispense_whipped_cream if options[:fluffy]
      dispose_of_grounds
    when :tea
      dispense_cup 
      heat_water 
      dispense_tea_bag 
      dispense_water
      dispense_sweetener if options[:sweet] 
      dispense_cream if options[:creamy]
    end
  end

  private

  def dispense_cup
    driver.dispense_cup
  end

  def heat_water
    driver.fill_tank
    driver.pressurize_tank
  end

  # ...
end

A small vending machine application

Each of the methods called from the case statement in the vend method interacts with a hardware driver to prepare the recipes.

We can probably all agree on a few things about this code:

  • The code is fairly readable.
  • There's quite a bit of duplication between the two recipes.
  • Future developers will likely add more beverages by adding more when clauses to the case statement.
  • At 25.1, the flog score for the vend method is already a little too high.

At this point, there are a couple of different paths forward:

  • We could DRY the code.
  • Or, we could introduce a layer of abstraction.

DRYing the code will simplify it on one axis. The Assignments, Branches, and Conditionals algorithm used by flog will produce a smaller number. Like this:

class VendingMachine
  attr_reader :driver

  def initialize(driver: Driver.new)
    @driver = driver
  end
  
  def vend(beverage:, options: {})
    dispense_cup 
    heat_water 

    case beverage
    when :coffee
      prepare_grounds 
    when :tea
      dispense_tea_bag 
    end

    dispense_water
    dispense_sweetener if options[:sweet]
    dispense_cream if options[:creamy]
    
    if beverage == :coffee
      dispense_whipped_cream if options[:fluffy]
      dispose_of_grounds
    end
  end

  
  private

  def dispense_cup
    driver.dispense_cup
  end

  def heat_water
    driver.fill_tank
    driver.pressurize_tank
  end

  # ...
end

A DRY version of the small vending machine application

The vend method now has a flog score of 16.1, which is nearly 10 points lower. Quite an improvement. But in choosing to DRY this code, we've tied the two recipes together, making it difficult to change one without affecting the other; and, we've made extending this code with additional recipes more difficult since we'll have to weave them together with the existing DRY recipes. Plus, there are at least 12 different code paths through the vend method as it currently exists making the method difficult to test.

Or, we could go with adding a layer of abstraction, like this:

class VendingMachine
  attr_reader :driver
  
  def initialize(driver:)
    @driver = driver
  end

  def vend(beverage:, options: {})
    case beverage
    when :coffee
      Coffee.new(driver:, options:).prepare
    when :tea
      Tea.new(driver:, options:).prepare
    end
  end
end

class Coffee < Recipe
  def prepare
    dispense_cup 
    heat_water 
    prepare_grounds 
    dispense_water
    dispense_sweetener if options[:sweet]
    dispense_cream if options[:creamy]
    dispense_whipped_cream if options[:fluffy]
    dispose_of_grounds
  end
end

class Tea < Recipe
  def prepare
    dispense_cup 
    heat_water 
    dispense_tea_bag 
    dispense_water
    dispense_sweetener if options[:sweet] 
    dispense_cream if options[:creamy]
  end
end

class Recipe
  attr_reader :driver

  def initialize(driver:)
    @driver = driver
  end

  private

  def dispense_cup
    driver.dispense_cup
  end

  def heat_water
    driver.fill_tank
    driver.pressurize_tank
  end

  # ...
end

An abstract version of the small vending machine application

Now, there are more lines of code, and as such, the flog score for what used to be the vend method is 35.0. However, the flog scores of the vend method and the two recipe classes are 6.1, 15.2, and 10.8 respectively. So, since we can only work on one thing at a time, we're always working with less complexity. And, we've not commingled the recipes. So, they're still as easy to understand as the original implementation.

Furthermore, future developers are likely to add new recipes by creating new classes instead of interleaving new recipes into the existing DRY code. This separates the recipes and allows each to be tested independently making each portion of the system easier to test.

The astute observer might point out that the prepare methods in the Coffee and Tea classes still contain duplication. These observers might even be tempted to DRY that duplication using something like the Template pattern. But, this would again intermingle the recipes. And, any new beverage would either have to comply with the template, or cause all of the algorithms to be rewritten unnecessarily.

This is the difference between DRY code and abstract code. DRY code might seem easier to write, but makes changing or extending an application harder. Abstract code may seem more difficult to write, but makes changing and extending the application simpler.

DRY is the easy solution. Abstraction is the simple solution.

Prefer simple over easy.

Subscribe to our occasional newsletter

No spam, no sharing to third party. Only you and me.

Member discussion