Welcome to the second in a series of posts in which we will answer the questions:

  • How does complexity sneak into software?
  • How can we recognize it before it becomes painful?
  • And, how can we remove it permanently?

Here's a link to the previous post in the series: What is complexity?

The Coffee Machine

It's time to get started on our little coffee machine application! Our first user story is: As a patron, I want coffee.

First, a bit of background...

We — you, me, and Fito — are the application team at our new coffee machine startup. We have a sibling hardware team who've promised to deliver an API that we can use to control the hardware from our application layer code. The interface upon which we agreed looks like this:

dispense_coffee_beans()
dispense_cup()
dispense_filter()
dispense_water()
engage_boiler(seconds:)
engage_grinder(seconds:)
empty_boiler()
empty_filter()
empty_grinder()

The code behind this interface probably sends specific voltages to different pieces of hardware to control the physical machine. We don't know. And, we don't care. It's none of our business.

As the application layer team, we only care that the implementation of the API is trustworthy. In other words, a cup should fall out of the cup hopper into the right position to receive coffee when we call dispense_cup.

This is our first lesson in complexity:

Trustworthy abstractions are at the heart of writing simple source code.

We'll see this pattern repeated later. But, for now, suffice to say that abstracting away the hardware interactions frees us up to focus on the high-level sequence of events that will produce a delicious cup of coffee using the machine's hardware.

Brewing Coffee

So, what does that look like? When a patron deposits their money, the machine should immediately prepare them a cup of coffee. Let's think through the steps the machine should perform in order to prepare the coffee. It should:

  • Dispense a cup
  • Heat some water
  • Grind some coffee beans
  • Pour the hot water over the grounds
  • And, dispose of the grounds

Tests First

Okay. Let's get started on some code...

To create our application layer code, we're going to write tests first, then write the code that makes the tests pass. We'll start with an integration test that ensures that the various methods on the driver get called in the right sequence to make a delicious cup of coffee. To do that, we'll need a mock implementation of our driver:

class MockDriver
  def dispense_cup; end
  def dispense_coffee_beans; end
  def dispense_water; end
  def engage_boiler(seconds:); end
  def engage_filter; end
  def engage_grinder(seconds:); end
  def empty_boiler; end
  def empty_filter; end
  def empty_grinder; end
end

So, our RSpec integration test will look like this:

RSpec.describe CoffeeMachine do
  describe "#vend" do
    example "As a patron, I want coffee" do
      # ARANGE
      driver = instance_double(
        MockDriver,
        dispense_cup: nil,
        dispense_coffee_beans: nil,
        dispense_water: nil,
        engage_boiler: nil,
        engage_filter: nil,
        engage_grinder: nil,
        empty_boiler: nil,
        empty_filter: nil,
        empty_grinder: nil
      )
      machine = CoffeeMachine.new(driver: driver)

      # ACT
      machine.vend

      # ASSERT
      # Dispense a cup
      expect(driver).to have_received(:dispense_cup).ordered

      # Heat some water
      expect(driver).to have_received(:dispense_water).ordered
      expect(driver).to have_received(:engage_boiler).with(seconds: 30).ordered

      # Grind some coffee beans
      expect(driver).to have_received(:dispense_coffee_beans).ordered
      expect(driver).to have_received(:engage_grinder).with(seconds: 15).ordered
      expect(driver).to have_received(:engage_filter).ordered
      expect(driver).to have_received(:empty_grinder).ordered

      # Pour the hot water over the grounds
      expect(driver).to have_received(:empty_boiler).ordered

      # Dispose of the grounds
      expect(driver).to have_received(:empty_filter).ordered
    end
  end
end

Note the comments in ALL CAPS. These comments are pointing out the three A's of good test design: arrange, act, and assert. When you structure a test like this, it's clear which commands are setup, which one is the method under test, and which ones are checking the results of the command.

We did not always write tests like this, with all three A's inside the it (or in this case example) block. We used to structure our tests quite differently. The arrange statements were usually in let blocks. The act statement was usually in a before block. And, only the assert statements went in the actual test block (often one assert per it).

But, after years of working in large code bases, where the spec files are gigantic and the tests can take dozens of lines to configure, the let statements at the top of the file (which are often shared across multiple tests) end up hundreds of lines away from the actual tests at the bottom of the file.

What's more, there are often intermediate let statements at the top of each nested context block that modify those at the top of the file. So, in order to understand the setup for a specific test, you need to trace through the entire file to reconstruct its setup.

Colocate the setup with the test — even if it means duplicating setup across tests. Your future self will thank you.

This brings us to our second lesson in complexity:

There is a balance to be struck between code reuse and code clarity.

As tempting as it might be to DRY up a bunch of tests with let statements, the larger the file gets, the further away they'll be from the code that uses them, making the tests themselves harder to understand.

Your tests need to be the most understandable bit of your codebase. They are the living documentation of what your software should do. If they're not clear, there's very little chance that the code will be either.

Our friends at thoughtbot have a couple of good posts on this subject:

The Code

Okay. With our first test in hand, it's time to write the production code:

class CoffeeMachine
  attr_reader :driver

  def initialize(driver:)
    @driver = driver
  end

  def vend
    driver.dispense_cup
    driver.dispense_water
    driver.engage_boiler(seconds: 30)
    driver.dispense_coffee_beans
    driver.engage_grinder(seconds: 15)
    driver.engage_filter
    driver.empty_grinder
    driver.empty_boiler
    driver.empty_filter
  end
end

This code passes the test. But, how does it compare to the high-level process we outlined before we wrote the test? Well, if we're being totally honest with ourselves, the act of brewing coffee is obscured by all the concrete details of manipulating the hardware, making this code hard to follow.

At this point, we might be tempted to annotate our code with comments:

class CoffeeMachine
  attr_reader :driver

  def initialize(driver:)
    @driver = driver
  end

  def vend
    # Dispense a cup
    driver.dispense_cup
    
    # Heat some water
    driver.dispense_water
    driver.engage_boiler(seconds: 30)
    
    # Grind some coffee beans
    driver.dispense_coffee_beans
    driver.engage_grinder(seconds: 15)
    driver.engage_filter
    driver.empty_grinder
    
    # Pour the hot water over the grounds
    driver.empty_boiler

    # Dispose of the grounds
    driver.empty_filter
  end
end

This is a common pattern. But, this is a tacit admission that the underlying code itself is hard to understand.

When Fito and I find ourselves confronted with code like this, we ensure the method is covered by tests (check!), run the tests to make sure they're passing (check!), and refactor the commented blocks of code into private methods, like this:

class CoffeeMachine
  attr_reader :driver

  def initialize(driver:)
    @driver = driver
  end

  def vend
    dispense_cup
    heat_water
    grind_coffee_beans
    dispense_hot_water  
    dispose_grounds
  end
  
  private
  
  def dispense_cup
    driver.dispense_cup
  end

  def heat_water
    driver.dispense_water
    driver.engage_boiler(seconds: 30)
  end

  def grind_coffee_beans
    driver.dispense_coffee_beans
    driver.engage_grinder(seconds: 15)
    driver.engage_filter
    driver.empty_grinder
  end
    
  def dispense_hot_water
    driver.empty_boiler
  end
    
  def dispose_grounds
    driver.empty_filter
  end
end

Now, the public #vend method clearly describes the process of brewing coffee, not how to use the machine to do it. This brings us to our third lesson:

Public methods should express what the class does, not how.

Leave the details to private methods with intention revealing names, rather than peppering your public methods with intention revealing comments. Those comments will start off truthful. But, in the end, they will always lie to you because they are not executable.

Conclusion

So, there you go! We've completed our first user story by writing a test, writing some code, and refactoring the code for clarity. Along the way, we learned three lessons:

  • Trustworthy abstractions are at the heart of writing simple source code.
  • There is a balance to be struck between code reuse and code clarity.
  • Public methods should express what the class does, not how.

We've reached the end of our second post in the series. In our next post, we'll tackle another user story: As a patron, I want tea.