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)
= self.attributes.merge(new_attributes)
attributes = type.constantize.new(attributes.except('id', 'type'))
new_question .id = id
new_question
begin
Question.transaction do
destroy.save!
new_questionend
rescue ActiveRecord::RecordInvalid
end
new_questionend
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
= answers.order(:created_at).pluck(:text)
text_from_ordered_answers .join(', ')
text_from_ordered_answersend
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)
.score(text)
submittableend
def breakdown
= answers.order(:created_at).pluck(:text)
text_from_ordered_answers .join(', ')
text_from_ordered_answersend
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
:score, to: :submittable 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
.steps
submittableend
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(:options, question.options_for_form) do |option_fields| -%>
<%= option_fields.input :text, 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(:options, submittable.options_for_form) do |option_fields| -%>
<%= option_fields.input :text, 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
= type.sub('Question', 'Submittable')
submittable_class_name .constantize.new(self)
submittable_class_nameend
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 theQuestion
class, and use that data from the strategy. This will result in divergent change and probably a large class onQuestion
, as every change in the data required for new or existing strategies will require new behavior onQuestion
. - 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 toQuestion.
This will avoid polluting theQuestion
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
:open_submittables do |table|
create_table .timestamps null: false
tableend
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
:question, as: :submittable
has_one
def breakdown
= answers.order(:created_at).pluck(:text)
text_from_ordered_answers .join(', ')
text_from_ordered_answersend
def score(text)
0
end
private
def answers
.answers
questionend
end
# app/models/question.rb
def submittable
= type.sub('Question', 'Submittable').constantize
submittable_class .new(question: self)
submittable_classend
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
:submittable, polymorphic: true belongs_to
And add the necessary columns:
# db/migrate/20130131203344_add_submittable_type_and_id_to_questions.rb
class AddSubmittableTypeAndIdToQuestions < ActiveRecord::Migration
def change
:questions, :submittable_id, :integer
add_column :questions, :submittable_type, :string
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
= type.sub('Question', 'Submittable').constantize
submittable_class self.submittable = submittable_class.new(question: 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)
= type.sub('Question', 'Submittable').constantize
submittable_class self.submittable = submittable_class.new(attributes.merge(question: 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
:scale_submittables, :minimum, :integer
add_column :scale_submittables, :maximum, :integer 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
:maximum, presence: true
validates :minimum, presence: true validates
And change ScaleSubmittable
methods to use those
attributes directly, rather than looking for them on
question
:
# app/models/scale_submittable.rb
def steps
..maximum).to_a
(minimumend
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 :submittable do |submittable_fields| -%>
<%= submittable_fields.input :minimum %>
<%= submittable_fields.input :maximum %>
<% end -%>
# app/models/question.rb
:submittable 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
:submittable, associated: true validates
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
.
paramsrequire(:question).
:title, :options_attributes)
permit(end
def submittable_params
if submittable_attributes = params[:question][:submittable_attributes]
.permit(:minimum, :maximum)
submittable_attributeselse
{}
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
:options, foreign_key: :question_id
has_many :question, as: :submittable
has_one
:options, reject_if: :all_blank 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
'open'
backfill 'multiple_choice'
backfill end
def down
.delete 'DELETE FROM open_submittables'
connection.delete 'DELETE FROM multiple_choice_submittables'
connectionend
private
def backfill(type)
"Backfilling #{type} submittables" do
say_with_time .update(<<-SQL)
connection UPDATE questions
SET
submittable_id = id,
submittable_type = '#{type.camelize}Submittable'
WHERE type = '#{type.camelize}Question'
SQL
.insert(<<-SQL)
connection 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)
= type.sub('Question', 'Submittable').constantize
submittable_class self.submittable = submittable_class.new(attributes.merge(question: self))
end
And pass it in when calling:
# app/controllers/questions_controller.rb
@question.build_submittable(type, submittable_params)
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
[:question][:submittable_type] params
# app/views/questions/_form.html.erb<%= form.hidden_field :submittable_type %>
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
:questions, :type
remove_column end
def down
:questions, :type, :string
add_column
.update(<<-SQL)
connection UPDATE questions
SET type = REPLACE(submittable_type, 'Submittable', 'Question')
SQL
:questions, :type, true
change_column_null end
end
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)
= submittable
old_submittable
build_submittable type, attributes
do
transaction if save
.destroy
old_submittableend
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.