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:
- Here's all the things I can make (by creating @makeables)
- 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
- J. B. Rainsberger has a write-up of making an object content-independent which describes a similar pattern
- The Dependency Injection principle describes how to configure an object by passing in its dependencies
Comments
You can follow this conversation by subscribing to the comment feed for this post.