Ruby Science
Extract Decorator
Decorators can be used to place new concerns on top of existing objects without modifying existing classes. They combine best with small classes containing few methods, and make the most sense when modifying the behavior of existing methods, rather than adding new methods.
The steps for extracting a decorator vary depending on the initial state, but they often include the following:
- Extract a new decorator class, starting with the alternative behavior.
- Compose the decorator in the original class.
- Move state specific to the alternate behavior into the decorator.
- Invert control, applying the decorator to the original class from its container, rather than composing the decorator from the original class.
It will be difficult to make use of decorators unless your application is following composition over inheritance.
Uses
- Eliminate large classes by extracting concerns.
- Eliminate divergent change and follow the single responsibility principle by adding new behavior without introducing new concerns to existing classes.
- Prevent conditional logic from leaking by making decisions earlier.
- Extend existing classes without modifying them, following the open/closed principle.
Example
In our example application, users can view a summary of the answers to each question on a survey. By default, in order to prevent the summary from influencing a user’s own answers, users don’t see summaries for questions they haven’t answered yet. Users can click a link to override this decision and view the summary for every question. This concern is mixed across several levels, and introducing the change affects several classes. Let’s see if we can refactor our application to make similar changes easier in the future.
Currently, the controller determines whether or not unanswered questions should display summaries:
# app/controllers/summaries_controller.rb
def constraints
if include_unanswered?
{}
else
{ answered_by: current_user }
end
end
def include_unanswered?
[:unanswered]
paramsend
It passes this decision into Survey#summaries_using
as a
hash containing Boolean flag:
# app/controllers/summaries_controller.rb
@summaries = @survey.summaries_using(summarizer, constraints)
Survey#summaries_using
uses this information to decide
whether each question should return a real summary or a hidden
summary:
# app/models/survey.rb
def summaries_using(summarizer, options = {})
.map do |question|
questionsif !options[:answered_by] || question.answered_by?(options[:answered_by])
.summary_using(summarizer)
questionelse
Summary.new(question.title, NO_ANSWER)
end
end
end
This method is pretty dense. We can start by using extract method to clarify and reveal complexity:
# app/models/survey.rb
def summaries_using(summarizer, options = {})
.map do |question|
questions[:answered_by])
summary_or_hidden_answer(summarizer, question, optionsend
end
private
def summary_or_hidden_answer(summarizer, question, answered_by)
if hide_unanswered_question?(question, answered_by)
hide_answer_to_question(question)else
.summary_using(summarizer)
questionend
end
def hide_unanswered_question?(question, answered_by)
&& !question.answered_by?(answered_by)
answered_by end
def hide_answer_to_question(question)
Summary.new(question.title, NO_ANSWER)
end
The summary_or_hidden_answer
method reveals a pattern
that’s well-captured by using a Decorator:
- There’s a base case: returning the real summary for the question’s answers.
- There’s an alternative, or decorated, case: returning a summary with a hidden answer.
- The conditional logic for using the base or decorated case is
unrelated to the base case:
answered_by
is only used for determining which path to take, and isn’t used by to generate summaries.
As a Rails developer, this may seem familiar to you: Many pieces of Rack middleware follow a similar approach.
Now that we’ve recognized this pattern, let’s refactor to use a decorator.
Move Decorated Case to Decorator
Let’s start by creating an empty class for the decorator and moving one method into it:
# app/models/unanswered_question_hider.rb
class UnansweredQuestionHider
NO_ANSWER = "You haven't answered this question".freeze
def hide_answer_to_question(question)
Summary.new(question.title, NO_ANSWER)
end
end
The method references a constant from Survey
, so we
moved that, too.
Now we update Survey
to compose our new class:
# app/models/survey.rb
def summary_or_hidden_answer(summarizer, question, answered_by)
if hide_unanswered_question?(question, answered_by)
UnansweredQuestionHider.new.hide_answer_to_question(question)
else
.summary_using(summarizer)
questionend
end
At this point, the decorated path is contained within the decorator.
Move Conditional Logic Into Decorator
Next, we can move the conditional logic into the decorator. We’ve
already extracted this to its own method on Survey
, so we
can simply move this method over:
# app/models/unanswered_question_hider.rb
def hide_unanswered_question?(question, user)
&& !question.answered_by?(user)
user end
Note that the answered_by
parameter was renamed to
user
. That’s because the context is more specific now, so
it’s clear what role the user is playing.
# app/models/survey.rb
def summary_or_hidden_answer(summarizer, question, answered_by)
= UnansweredQuestionHider.new
hider if hider.hide_unanswered_question?(question, answered_by)
.hide_answer_to_question(question)
hiderelse
.summary_using(summarizer)
questionend
end
Move Body Into Decorator
There’s
just one summary-related method left in Survey
:
summary_or_hidden_answer
. Let’s move this into the
decorator:
# app/models/unanswered_question_hider.rb
def summary_or_hidden_answer(summarizer, question, user)
if hide_unanswered_question?(question, user)
hide_answer_to_question(question)else
.summary_using(summarizer)
questionend
end
# app/models/survey.rb
def summaries_using(summarizer, options = {})
.map do |question|
questionsUnansweredQuestionHider.new.summary_or_hidden_answer(
summarizer,
question,[:answered_by]
options
)end
end
At this point, every other method in the decorator can be made private.
Promote Parameters to Instance Variables
Now that we have a class to handle this logic, we can move some of
the parameters into instance state. In
Survey#summaries_using
, we use the same summarizer and user
instance; only the question varies as we iterate through questions to
summarize. Let’s move everything but the question into instance
variables on the decorator:
# app/models/unanswered_question_hider.rb
def initialize(summarizer, user)
@summarizer = summarizer
@user = user
end
def summary_or_hidden_answer(question)
if hide_unanswered_question?(question)
hide_answer_to_question(question)else
.summary_using(@summarizer)
questionend
end
# app/models/survey.rb
def summaries_using(summarizer, options = {})
.map do |question|
questionsUnansweredQuestionHider.new(summarizer, options[:answered_by]).
summary_or_hidden_answer(question)end
end
Our
decorator now just needs a question
to generate a
Summary
.
Change Decorator to Follow Component Interface
In the end, the component we want to wrap with our decorator is the summarizer, so we want the decorator to obey the same interface as its component—the summarizer. Let’s rename our only public method so that it follows the summarizer interface:
# app/models/unanswered_question_hider.rb
def summarize(question)
# app/models/survey.rb
UnansweredQuestionHider.new(summarizer, options[:answered_by]).
summarize(question)
Our
decorator now follows the component interface in name—but not
behavior. In our application, summarizers return a string that
represents the answers to a question, but our decorator is returning a
Summary
instead. Let’s fix our decorator to follow the
component interface by returning just a string:
# app/models/unanswered_question_hider.rb
def summarize(question)
if hide_unanswered_question?(question)
hide_answer_to_question(question)else
@summarizer.summarize(question)
end
end
# app/models/unanswered_question_hider.rb
def hide_answer_to_question(question)
NO_ANSWER
end
# app/models/survey.rb
def summaries_using(summarizer, options = {})
.map do |question|
questions= UnansweredQuestionHider.new(summarizer, options[:answered_by])
hider .summary_using(hider)
questionend
end
Our decorator now follows the component interface.
That last method on the decorator
(hide_answer_to_question
) isn’t pulling its weight anymore:
It just returns the value from a constant. Let’s inline
it to slim down our class a bit:
# app/models/unanswered_question_hider.rb
def summarize(question)
if hide_unanswered_question?(question)
NO_ANSWER
else
@summarizer.summarize(question)
end
end
Now we have a decorator that can wrap any summarizer, nicely-factored and ready to use.
Invert Control
Now comes one of the most important steps: We can invert
control by removing any reference to the decorator from
Survey
and passing in an already-decorated summarizer.
The summaries_using
method is simplified:
# app/models/survey.rb
def summaries_using(summarizer)
.map do |question|
questions.summary_using(summarizer)
questionend
end
Instead of passing the Boolean flag down from the controller, we can make the decision to decorate there and pass a decorated or undecorated summarizer:
# app/controllers/summaries_controller.rb
def show
@survey = Survey.find(params[:survey_id])
@summaries = @survey.summaries_using(decorated_summarizer)
end
private
def decorated_summarizer
if include_unanswered?
summarizerelse
UnansweredQuestionHider.new(summarizer, current_user)
end
end
This isolates the decision to one class and keeps the result of the decision close to the class that makes it.
Another important effect of this refactoring is that the
Survey
class is now reverted back to the
way it was before we started hiding unanswered question summaries.
This means that we can now add similar changes without modifying
Survey
at all.
Drawbacks
- Decorators must keep up to date with their component interface. Our decorator follows the summarizer interface. Every decorator we add for this interface is one more class that will need to change any time we change the interface.
- We removed a concern from
Survey
by hiding it behind a decorator, but this may make it harder for a developer to understand how aSurvey
might return the hidden response text, since that text doesn’t appear anywhere in that class. - The component we decorated had the smallest possible interface: one public method. Classes with more public methods are more difficult to decorate.
- Decorators can modify methods in the component interface easily, but
adding new methods won’t work with multiple decorators without
meta-programming like
method_missing
. These constructs are harder to follow and should be used with care.
Next Steps
- It’s unlikely that your automated test suite has enough coverage to check every component implementation with every decorator. Run through the application in a browser after introducing new decorators. Test and fix any issues you run into.
- Make sure that inverting control didn’t push anything over the line into a large class.
Ruby Science
The canonical reference for writing fantastic Rails applications from authors who have created hundreds.