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:

  1. Extract a new decorator class, starting with the alternative behavior.
  2. Compose the decorator in the original class.
  3. Move state specific to the alternate behavior into the decorator.
  4. 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

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
    { current_user }
  end
end

def include_unanswered?
  params[]
end

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 = {})
  questions.map do |question|
    if !options[] || question.answered_by?(options[])
      question.summary_using(summarizer)
    else
      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 = {})
  questions.map do |question|
    summary_or_hidden_answer(summarizer, question, options[])
  end
end

private

def summary_or_hidden_answer(summarizer, question, answered_by)
  if hide_unanswered_question?(question, answered_by)
    hide_answer_to_question(question)
  else
    question.summary_using(summarizer)
  end
end

def hide_unanswered_question?(question, answered_by)
  answered_by && !question.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
    question.summary_using(summarizer)
  end
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)
  user && !question.answered_by?(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)
  hider = UnansweredQuestionHider.new
  if hider.hide_unanswered_question?(question, answered_by)
    hider.hide_answer_to_question(question)
  else
    question.summary_using(summarizer)
  end
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
    question.summary_using(summarizer)
  end
end
# app/models/survey.rb
def summaries_using(summarizer, options = {})
  questions.map do |question|
    UnansweredQuestionHider.new.summary_or_hidden_answer(
      summarizer,
      question,
      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
    question.summary_using(@summarizer)
  end
end
# app/models/survey.rb
def summaries_using(summarizer, options = {})
  questions.map do |question|
    UnansweredQuestionHider.new(summarizer, options[]).
      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[]).
  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 = {})
  questions.map do |question|
    hider = UnansweredQuestionHider.new(summarizer, options[])
    question.summary_using(hider)
  end
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)
  questions.map do |question|
    question.summary_using(summarizer)
  end
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[])
  @summaries = @survey.summaries_using(decorated_summarizer)
end

private

def decorated_summarizer
  if include_unanswered?
    summarizer
  else
    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 a Survey 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.

Work with us to make a new Rails app, or to maintain, improve, or scale your existing app.