Dan Wellman's Blog

Code Pattern: Separate Configuration from Behavior

Problem

You have an object that requires data from some other part of your system to do its job. The object has some code that gathers the data it needs from the other parts of the system, as well as methods to perform its services using that data.

You notice: - Tests for the object make surprising references to other parts of the system, leading the program reader to wonder, "Where did this come from?". Tests may be hard to write in the first place because of this problem. - The object is not able to be used in another context because it only supports one set of data - Changes to the data found in other parts of the system require changing this object as well as those other modules.

These problems can arise when an object knows how to collect the data it needs for the specific scenario it supports. The object's behavior is intermixed with its construction.

Therefore:

Pass in the data the object needs as a parameter, either via the constructor or as a method parameter. Move the logic that collects the data to an earlier step in the program, either handled by the object that calls this one, or to some code that constructs a group of objects needed for this particular scenario. For some programs, this might be in the entry point (for example, the main() function in a Java standalone program).

Example:

Bane has an object called ServiceMaker which is responsible for creating services. There are several different types of services, each of which may be created in different ways. The ServiceMaker simplifies this so that clients only need ask for objects by name and the ServiceMaker returns them a correctly instantiated instance.

The ServiceMaker keeps a collection of all the things it knows how to make as a map where keys are the names of the services that can be created, and the values are objects that respond to the 'make' message.

For example, that map might look something like:

@makeables = { 'NeverListen' => an object, 'NeverRespond' => an object, … }

At the beginning of the refactoring, the code for the ServiceMaker looked something like this:

class ServiceMaker
  def initialize
    @makeables = initialize_makeables
  end

  def create(name)
    # Simplified - for example
    @makeables.fetch(name).make(...)   
  end

  # Other methods omitted

  private

  def initialize_makeables
    # Return a hash by looking in some Bane constants
    # The specifics of how these are created are not important,
    # but it is important to know that this code has some logic.
    Bane::Services::EXPORTED.map { … }
      .append(Bane::Behaviors::EXPORTED.map { … })
  end
end

Which referred to some constants:

module Bane
  module Services
    EXPORTED = ... # a list of services
  end
end

module Bane
  module Behaviors
    EXPORTED = ... # another list of services
  end
end

With a sample test:

def test_can_make_services_and_behaviors
    maker = ServiceMaker.new
    service = maker.create('NeverRespond')
    # verify the NeverRespond service is created
end

In ServiceMaker's initialize_makeables you see that it uses a few different constants and applies some sort of map function, adds the hashes together, and returns a Hash object.

In addition, the tests for the ServiceMaker require some setup, since they depend upon constants from outside of this class. By looking at the test code, it's not obvious how the known set of "makeables" is constructed:

def test_can_make_services_and_behaviors
    maker = ServiceMaker.new
    service = maker.create('NeverRespond')
    # verify the NeverRespond service is created
end

Note that the test code asks for the 'NeverRespond' service by name - but nowhere in the test is it clear where that name comes from. It's not obvious whether the ServiceMaker should be able to create one of those or not!

The ServiceMaker object is context dependent; it knows about how it fits in with the rest of the system. You can see that it references the Bane::Services::EXPORTED and Bane::Behaviors::EXPORTED constants.

This class knows two things:

  1. Here's all the things I can make (by creating @makeables)
  2. Here's how I make the things I know how to make (using @makeables and sending the make message)

If I add more types of makeables, it would mean that I'd need to change code in the ServiceMaker object. That doesn't feel like such a big problem to me; Bane is not so big that I am worried I won't know where this code would come from. But it also means that any time I wanted to change the list of makeables, I'd need to change the EXPORTED constants (possibly adding or removing some new sources of makeables) as well as the ServiceMaker. This breaks the "Open/Closed Principle" - that classes should be closed for modification, but open for extension. Or said another way, it should be possible to change the behavior of the system by adding new objects rather than editing inside of classes.

To fix this problem, I can pass in all the things I can make to the object's constructor instead of creating them inside the ServiceMaker:

class ServiceMaker
  def initialize(makeables)
    @makeables = makeables
  end

  def create(name)
    # Simplified - for example
    @makeables.fetch(name).make(...)   
  end

  # Other methods omitted

end

Now the ServiceMaker object is only responsible for creating the things it knows how to make. We put the code to create the map of services somewhere higher up in the stack like so:

makeables = Bane::Services::EXPORTED.map { … }
      .append(Bane::Behaviors::EXPORTED.map { … })
maker = ServiceMaker.new(makeables)
# ...

This separates the program's configuration from its behavior. We specify how our object graph is created at the beginning of the program, and then the program executes.

Now the tests for ServiceMaker are much simpler, because we can pass in something concrete:

def test_makes_the_specified_makeable_given_its_name
    maker = ServiceMaker.new({'NeverRespond' => FakeMaker.new('I never respond')})
    service = maker.create('NeverRespond')
    assert_equal 'I never respond', service
end

class FakeMaker

  def initialize(description)
    @description = description
  end

  def make(...)
    @description
  end
end

Note that we can pass in a test double (FakeMaker) that is simple to use for our test; this fake maker responds to the 'make' that returns the given description.

Contraindications

If your object is part of a library that you distribute and you want to protect users from incorrectly configuring your object, you might want to keep the configuration as part of the object, or provide a way for users to get a correctly configured version of your object through a Factory Method.

Other References