Ruby Science

Replace Subclasses with Strategies

Subclasses are a common method of achieving reuse and polymorphism, but inheritance has its drawbacks. See composition over inheritance for reasons why you might decide to avoid an inheritance-based model.

During this refactoring, we will replace the subclasses with individual strategy classes. Each strategy class will implement a common interface. The original base class is promoted from an abstract class to the composition root, which composes the strategy classes.

This allows for smaller interfaces, stricter separation of concerns and easier testing. It also makes it possible to swap out part of the structure, which, in an inheritance-based model, would require converting to a new type.

When applying this refactoring to an ActiveRecord::Base subclass, STI is removed, often in favor of a polymorphic association.

Uses

  • Eliminates large classes by splitting up a bloated base class.
  • Converts STI to a composition-based scheme.
  • Makes it easier to change part of the structure by separating the parts that change from the parts that don’t.

Example

The switch_to method on Question changes the question to a new type. Any necessary attributes for the new subclass are provided to the attributes method.

# app/models/question.rb
def switch_to(type, new_attributes)
  attributes = self.attributes.merge(new_attributes)
  new_question = type.constantize.new(attributes.except('id', 'type'))
  new_question.id = id

  begin
    Question.transaction do
      destroy
      new_question.save!
    end
  rescue ActiveRecord::RecordInvalid
  end

  new_question
end

Using inheritance makes changing question types awkward for a number of reasons:

  • You can’t actually change the class of an instance in Ruby, so you need to return the instance of the new class.
  • The implementation requires deleting and creating records, but part of the transaction (destroy) must execute before we can validate the new instance. This results in control flow using exceptions.
  • It’s hard to understand why this method is implemented the way it is, so other developers fixing bugs or refactoring in the future will have a hard time navigating it.

We can make this operation easier by using composition instead of inheritance.

This is a difficult change that becomes larger as more behavior is added to the inheritance tree. We can make the change easier by breaking it down into smaller steps, ensuring that the application is in a fully functional state with passing tests after each change. This allows us to debug in smaller sessions and create safe checkpoint commits that we can retreat to if something goes wrong.

Use Extract Class to Extract Non-Railsy Methods from Subclasses

The easiest way to start is by extracting a strategy class from each subclass and moving (and delegating) as many methods as you can to the new class. There’s some class-level wizardry that goes on in some Rails features, like associations, so let’s start by moving simple, instance-level methods that aren’t part of the framework.

Let’s start with a simple subclass: OpenQuestion.

Here’s the OpenQuestion class using an STI model:

# app/models/open_question.rb
class OpenQuestion < Question
  def score(text)
    0
  end

  def breakdown
    text_from_ordered_answers = answers.order().pluck()
    text_from_ordered_answers.join(', ')
  end
end

We can start by creating a new strategy class:

class OpenSubmittable
end

When switching from inheritance to composition, you need to add a new word to the application’s vocabulary. Before, we had questions, and different subclasses of questions handled the variations in behavior and data. Now, we’re switching to a model where there’s only one question class, and the question will compose something that will handle the variations. In our case, that something is a “submittable.” In our new model, each question is just a question, and every question composes a submittable that decides how the question can be submitted. Thus, our first extracted class is called OpenSubmittable, extracted from OpenQuestion.

Let’s move our first method over to OpenSubmittable:

# app/models/open_submittable.rb
class OpenSubmittable
  def score(text)
    0
  end
end

And change OpenQuestion to delegate to it:

# app/models/open_question.rb
class OpenQuestion < Question
  def score(text)
    submittable.score(text)
  end

  def breakdown
    text_from_ordered_answers = answers.order().pluck()
    text_from_ordered_answers.join(', ')
  end

  def submittable
    OpenSubmittable.new
  end
end

Each question subclass implements the score method, so we repeat this process for MultipleChoiceQuestion and ScaleQuestion. You can see the full change for this step in the example app.

At this point, we’ve introduced a parallel inheritance hierarchy. During a longer refactor, things may get worse before they get better. This is one of several reasons that it’s always best to refactor in a branch, separately from any feature work. We’ll make sure that the parallel inheritance hierarchy is removed before merging.

Pull Up Delegate Method into Base Class

After the first step, each subclass implements a submittable method to build its parallel strategy class. The score method in each subclass simply delegates to its submittable. We can now pull the score method up into the base Question class, completely removing this concern from the subclasses.

First, we add a delegator to Question:

# app/models/question.rb
delegate , 

Then, we remove the score method from each subclass.

You can see this change in full in the example app.

Move Remaining Common API into Strategies

We can now repeat the first two steps for every non-Railsy method that the subclasses implement. In our case, this is just the breakdown method.

The most interesting part of this change is that the breakdown method requires state from the subclasses, so the question is now provided to the submittable:

# app/models/multiple_choice_question.rb
def submittable
  MultipleChoiceSubmittable.new(self)
end
# app/models/multiple_choice_submittable.rb
def answers
  @question.answers
end

def options
  @question.options
end

You can view this change in the example app.

Move Remaining Non-Railsy Public Methods into Strategies

We can take a similar approach for the uncommon API; that is, public methods that are only implemented in one subclass.

First, move the body of the method into the strategy:

# app/models/scale_submittable.rb
def steps
  (@question.minimum..@question.maximum).to_a
end

Then, add a delegator. This time, the delegator can live directly on the subclass, rather than the base class:

# app/models/scale_question.rb
def steps
  submittable.steps
end

Repeat this step for the remaining public methods that aren’t part of the Rails framework. You can see the full change for this step in our example app.

Remove Delegators from Subclasses

Our subclasses now contain only delegators, code to instantiate the submittable, and framework code. Eventually, we want to completely delete these subclasses, so let’s start stripping them down. The delegators are easiest to delete, so let’s take them on before the framework code.

First, find where the delegators are used:

# app/views/multiple_choice_questions/_multiple_choice_question_form.html.erb
<%= form.fields_for(, question.options_for_form) do |option_fields| -%>
  <%= option_fields.input , label: 'Option' %>
<% end -%>

And change the code to directly use the strategy instead:

# app/views/multiple_choice_questions/_multiple_choice_question_form.html.erb
<%= form.fields_for(, submittable.options_for_form) do |option_fields| -%>
  <%= option_fields.input , label: 'Option' %>
<% end -%>

You may need to pass the strategy in where the subclass was used before:

# app/views/questions/_form.html.erb
<%= render(
  "#{question.to_partial_path}_form",
  submittable: question.submittable,
  form: form
) %>

We can come back to these locations later and see if we need to pass in the question at all.

After fixing the code that uses the delegator, remove the delegator from the subclass. Repeat this process for each delegator until they’ve all been removed.

You can see how we do this in the example app.

Instantiate Strategy Directly from Base Class

If you look carefully at the submittable method from each question subclass, you’ll notice that it simply instantiates a class based on its own class name and passes itself to the initialize method:

# app/models/open_question.rb
def submittable
  OpenSubmittable.new(self)
end

This is a pretty strong convention, so let’s apply some convention over configuration and pull the method up into the base class:

# app/models/question.rb
def submittable
  submittable_class_name = type.sub('Question', 'Submittable')
  submittable_class_name.constantize.new(self)
end

We can then delete submittable from each of the subclasses.

At this point, the subclasses contain only Rails-specific code, like associations and validations.

You can see the full change in the example app.

Also, note that you may want to scope the constantize call in order to make the strategies easy for developers to discover and close potential security vulnerabilities.

A Fork in the Road

At this point, we’re faced with a difficult decision. At first glance, it seems as though only associations and validations live in our subclasses, and we could easily move those to our strategy. However, there are two major issues.

First, you can’t move the association to a strategy class without making that strategy an ActiveRecord::Base subclass. Associations are deeply coupled with ActiveRecord::Base and they simply won’t work in other situations.

Also, one of our submittable strategies has state specific to that strategy. Scale questions have a minimum and maximum. These fields are only used by scale questions, but they’re on the questions table. We can’t remove this pollution without creating a table for scale questions.

There are two obvious ways to proceed:

  • Continue without making the strategies ActiveRecord::Base subclasses. Keep the association for multiple choice questions and the minimum and maximum for scale questions on the Question class, and use that data from the strategy. This will result in divergent change and probably a large class on Question, as every change in the data required for new or existing strategies will require new behavior on Question.
  • Convert the strategies to ActiveRecord::Base subclasses. Move the association and state specific to strategies to those classes. This involves creating a table for each strategy and adding a polymorphic association to Question. This will avoid polluting the Question class with future strategy changes, but is awkward right now, because the tables for multiple choice questions and open questions would contain no data except the primary key. These tables provide a placeholder for future strategy-specific data, but those strategies may never require any more data and until they do, the tables are a waste of queries and the developer’s mental space.

In this example, we’ll move forward with the second approach, because:

  • It’s easier with ActiveRecord. ActiveRecord will take care of instantiating the strategy in most situations if it’s an association, and it has special behavior for associations using nested attribute forms.
  • It’s the easiest way to avoid divergent change and large classes in a Rails application. Both of these smells can cause problems that are hard to fix if you wait too long.

Convert Strategies to ActiveRecord Subclasses

Continuing with our refactor, we’ll change each of our strategy classes to inherit from ActiveRecord::Base.

First, simply declare that the class is a child of ActiveRecord::Base:

# app/models/open_submittable.rb
class OpenSubmittable < ActiveRecord::Base

Your tests will complain that the corresponding table doesn’t exist, so create it:

# db/migrate/20130131205432_create_open_submittables.rb
class CreateOpenSubmittables < ActiveRecord::Migration
  def change
    create_table  do |table|
      table.timestamps false
    end
  end
end

Our strategies currently accept the question as a parameter to initialize and assign it as an instance variable. In an ActiveRecord::Base subclass, we don’t control initialize, so let’s change question from an instance variable to an association and pass a hash:

# app/models/open_submittable.rb
class OpenSubmittable < ActiveRecord::Base
  has_one , 

  def breakdown
    text_from_ordered_answers = answers.order().pluck()
    text_from_ordered_answers.join(', ')
  end

  def score(text)
    0
  end

  private

  def answers
    question.answers
  end
end
# app/models/question.rb
def submittable
  submittable_class = type.sub('Question', 'Submittable').constantize
  submittable_class.new(self)
end

Our strategies are now ready to use Rails-specific functionality, like associations and validations.

View the full change on GitHub.

Introduce a Polymorphic Association

Now that our strategies are persistable using ActiveRecord, we can use them in a polymorphic association. Let’s add the association:

# app/models/question.rb
belongs_to , true

And add the necessary columns:

# db/migrate/20130131203344_add_submittable_type_and_id_to_questions.rb
class AddSubmittableTypeAndIdToQuestions < ActiveRecord::Migration
  def change
    add_column , , 
    add_column , , 
  end
end

We’re currently defining a submittable method that overrides the association. Let’s change that to a method that will build the association based on the STI type:

# app/models/question.rb
def build_submittable
  submittable_class = type.sub('Question', 'Submittable').constantize
  self.submittable = submittable_class.new(self)
end

Previously, the submittable method built the submittable on demand, but now it’s persisted in an association and built explicitly. Let’s change our controllers accordingly:

# app/controllers/questions_controller.rb
def build_question
  @question = type.constantize.new(question_params)
  @question.build_submittable
  @question.survey = @survey
end

View the full change on GitHub.

Pass Attributes to Strategies

We’re persisting the strategy as an association, but the strategies currently don’t have any state. We need to change that, since scale submittables need a minimum and maximum.

Let’s change our build_submittable method to accept attributes:

# app/models/question.rb
def build_submittable(attributes)
  submittable_class = type.sub('Question', 'Submittable').constantize
  self.submittable = submittable_class.new(attributes.merge(self))
end

We can quickly change the invocations to pass an empty hash, and we’re back to green.

Next, let’s move the minimum and maximum fields over to the scale_submittables table:

# db/migrate/20130131211856_move_scale_question_state_to_scale_submittable.rb
add_column , , 
add_column , , 

Note that this migration is rather lengthy, because we also need to move over the minimum and maximum values for existing questions. The SQL in our example app will work on most databases, but is cumbersome. If you’re using PostgreSQL, you can handle the down method easier using an UPDATE FROM statement.

Next, we’ll move validations for these attributes over from ScaleQuestion:

# app/models/scale_submittable.rb
validates , true
validates , true

And change ScaleSubmittable methods to use those attributes directly, rather than looking for them on question:

# app/models/scale_submittable.rb
def steps
  (minimum..maximum).to_a
end

We can pass those attributes in our form by using fields_for and accepts_nested_attributes_for:

# app/views/scale_questions/_scale_question_form.html.erb
<%= form.fields_for  do |submittable_fields| -%>
  <%= submittable_fields.input  %>
  <%= submittable_fields.input  %>
<% end -%>
# app/models/question.rb
accepts_nested_attributes_for 

In order to make sure the Question fails when its submittable is invalid, we can cascade the validation:

# app/models/question.rb
validates , true

Now, we just need our controllers to pass the appropriate submittable parameters:

# app/controllers/questions_controller.rb
def build_question
  @question = type.constantize.new(question_params)
  @question.build_submittable(submittable_params)
  @question.survey = @survey
end
# app/controllers/questions_controller.rb
def question_params
  params.
    require().
    permit(, )
end

def submittable_params
  if submittable_attributes = params[][]
    submittable_attributes.permit(, )
  else
    {}
  end
end

All behavior and state is now moved from ScaleQuestion to ScaleSubmittable, and the ScaleQuestion class is completely empty.

You can view the full change in the example app.

Move Remaining Railsy Behavior Out of Subclasses

We can now repeat this process for remaining Rails-specific behavior. In our case, this is the logic to handle the options association for multiple choice questions.

We can move the association and behavior over to the strategy class:

# app/models/multiple_choice_submittable.rb
has_many , 
has_one , 

accepts_nested_attributes_for , 

Again, we remove the options method which delegated to question and rely on options being directly available. Then we update the form to use fields_for and move the allowed attributes in the controller from question to submittable.

At this point, every question subclass is completely empty.

You can view the full change in the example app.

Backfill Strategies for Existing Records

Now that everything is moved over to the strategies, we need to make sure that submittables exist for every existing question. We can write a quick backfill migration to take care of that:

# db/migrate/20130207164259_backfill_submittables.rb
class BackfillSubmittables < ActiveRecord::Migration
  def up
    backfill 'open'
    backfill 'multiple_choice'
  end

  def down
    connection.delete 'DELETE FROM open_submittables'
    connection.delete 'DELETE FROM multiple_choice_submittables'
  end

  private

  def backfill(type)
    say_with_time "Backfilling #{type}  submittables" do
      connection.update(<<-SQL)
        UPDATE questions
        SET
          submittable_id = id,
          submittable_type = '#{type.camelize}Submittable'
        WHERE type = '#{type.camelize}Question'
      SQL
      connection.insert(<<-SQL)
        INSERT INTO #{type}_submittables
          (id, created_at, updated_at)
        SELECT
          id, created_at, updated_at
        FROM questions
        WHERE questions.type = '#{type.camelize}Question'
      SQL
    end
  end
end

We don’t port over scale questions, because we took care of them in a previous migration.

Pass the Type When Instantiating the Strategy

At this point, the subclasses are just dead weight. However, we can’t delete them just yet. We’re relying on the type column to decide what type of strategy to build, and Rails will complain if we have a type column without corresponding subclasses.

Let’s remove our dependence on that type column. Accept a type when building the submittable:

# app/models/question.rb
def build_submittable(type, attributes)
  submittable_class = type.sub('Question', 'Submittable').constantize
  self.submittable = submittable_class.new(attributes.merge(self))
end

And pass it in when calling:

# app/controllers/questions_controller.rb
@question.build_submittable(type, submittable_params)

Full Change

Always Instantiate the Base Class

Now we can remove our dependence on the STI subclasses by always building an instance of Question.

In our controller, we change this line:

# app/controllers/questions_controller.rb
@question = type.constantize.new(question_params)

To this:

# app/controllers/questions_controller.rb
@question = Question.new(question_params)

We’re still relying on type as a parameter in forms and links to decide what type of submittable to build. Let’s change that to submittable_type, which is already available because of our polymorphic association:

# app/controllers/questions_controller.rb
params[][]
# app/views/questions/_form.html.erb
<%= form.hidden_field  %>

We’ll also need to revisit views that rely on polymorphic partials based on the question type and change them to rely on the submittable type instead:

# app/views/surveys/show.html.erb
<%= render(
  question.submittable,
  submission_fields: submission_fields
) %>

Now we can finally remove our type column entirely:

# db/migrate/20130207214017_remove_questions_type.rb
class RemoveQuestionsType < ActiveRecord::Migration
  def up
    remove_column , 
  end

  def down
    add_column , , 

    connection.update(<<-SQL)
      UPDATE questions
      SET type = REPLACE(submittable_type, 'Submittable', 'Question')
    SQL

    change_column_null , , true
  end
end

Full Change

Remove Subclasses

Now for a quick, glorious change: those Question subclasses are entirely empty and unused, so we can delete them.

This also removes the parallel inheritance hierarchy that we introduced earlier.

At this point, the code is as good as we found it.

Simplify Type Switching

If you were previously switching from one subclass to another as we did to change question types, you can now greatly simplify that code.

Instead of deleting the old question and cloning it with a merged set of old generic attributes and new specific attributes, you can simply swap in a new strategy for the old one.

# app/models/question.rb
def switch_to(type, attributes)
  old_submittable = submittable
  build_submittable type, attributes

  transaction do
    if save
      old_submittable.destroy
    end
  end
end

Our new switch_to method is greatly improved:

  • This method no longer needs to return anything, because there’s no need to clone. This is nice because switch_to is no longer a mixed command and query method (i.e., it does something and returns something), but simply a command method (i.e., it just does something).
  • The method no longer needs to delete the old question, and the new submittable is valid before we delete the old one. This means we no longer need to use exceptions for control flow.
  • It’s simpler and its code is obvious, so other developers will have no trouble refactoring or fixing bugs.

You can see the full change that resulted in our new method in the example app.

Conclusion

Our new, composition-based model is improved in a number of ways:

  • It’s easy to change types.
  • Each submittable is easy to use independently of its question, reducing coupling.
  • There’s a clear boundary in the API for questions and submittables, making it easier to test—and less likely that concerns leak between the two.
  • Shared behavior happens via composition, making it less likely that the base class will become a large class.
  • It’s easy to add new state without affecting other types, because strategy-specific state is stored on a table for that strategy.

You can view the entire refactor with all steps combined in the example app to get an idea of what changed at the macro level.

This is a difficult transition to make, and the more behavior and data that you shove into an inheritance scheme, the harder it becomes. Regarding situations in which STI is not significantly easier than using a polymorphic relationship, it’s better to start with composition. STI provides few advantages over composition, and it’s easier to merge models than to split them.

Drawbacks

Our application also got worse in a number of ways:

  • We introduced a new word into the application vocabulary. This can increase understanding of a complex system, but vocabulary overload makes simpler systems unnecessarily hard to learn.
  • We now need two queries to get a question’s full state, and we’ll need to query up to four tables to get information about a set of questions.
  • We introduced useless tables for two of our question types. This will happen whenever you use ActiveRecord to back a strategy without state.
  • We increased the overall complexity of the system. In this case, it may have been worth it, because we reduced the complexity per component. However, it’s worth keeping an eye on.

Before performing a large change like this, try to imagine what currently difficult changes will be easier to make in the new version.

After performing a large change, keep track of difficult changes you make. Would they have been easier in the old version?

Answering these questions will increase your ability to judge whether or not to use composition or inheritance in future situations.

Next Steps

  • Check the extracted strategy classes to make sure they don’t have feature envy related to the original base class. You may want to use move method to move methods between strategies and the root class.
  • Check the extracted strategy classes for duplicated code introduced while splitting up the base class. Use extract method or extract class to extract common behavior.

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.