Ruby Science
Extract Class
Dividing responsibilities into classes is the primary way to manage complexity in object-oriented software. Extract class is the primary mechanism for introducing new classes. This refactoring takes one class and splits it into two by moving one or more methods and instance variables into a new class.
The process for extracting a class looks like this:
- Create a new, empty class.
- Instantiate the new class from the original class.
- Move a method from the original class to the new class.
- Repeat step 3 until you’re happy with the original class.
Uses
- Removes large class by splitting up the class.
- Eliminates divergent change by moving one reason to change into a new class.
- Provides a cohesive set of functionality with a meaningful name, making it easier to understand and talk about.
- Fully encapsulates a concern within a single class, following the single responsibility principle and making it easier to change and reuse that functionality.
- Allows concerns to be injected, following the dependency inversion principle.
- Makes behavior easier to reuse, which makes it easier to avoid duplication.
Example
The InvitationsController
is a large class hidden behind a long method:
# app/controllers/invitations_controller.rb
class InvitationsController < ApplicationController
EMAIL_REGEX = /\A([^@\s]+)@((?:[-a-z0-9]+\.)+[a-z]{2,})\z/
def new
@survey = Survey.find(params[:survey_id])
end
def create
@survey = Survey.find(params[:survey_id])
@recipients = params[:invitation][:recipients]
= @recipients.gsub(/\s+/, '').split(/[\n,;]+/)
recipient_list
@invalid_recipients = recipient_list.map do |item|
unless item.match(EMAIL_REGEX)
itemend
end.compact
@message = params[:invitation][:message]
if @invalid_recipients.empty? && @message.present?
.each do |email|
recipient_list= Invitation.create(
invitation survey: @survey,
sender: current_user,
recipient_email: email,
status: 'pending'
)Mailer.invitation_notification(invitation, @message)
end
@survey), notice: 'Invitation successfully sent'
redirect_to survey_path(else
'new'
render end
end
end
Although it contains only two methods, there’s a lot going on under the hood. It parses and validates emails, manages several pieces of state which the view needs to know about, handles control flow for the user and creates and delivers invitations.
A liberal application of extract method to break up this long method will reveal the complexity:
# app/controllers/invitations_controller.rb
class InvitationsController < ApplicationController
EMAIL_REGEX = /\A([^@\s]+)@((?:[-a-z0-9]+\.)+[a-z]{2,})\z/
def new
@survey = Survey.find(params[:survey_id])
end
def create
@survey = Survey.find(params[:survey_id])
if valid_recipients? && valid_message?
.each do |email|
recipient_list= Invitation.create(
invitation survey: @survey,
sender: current_user,
recipient_email: email,
status: 'pending'
)Mailer.invitation_notification(invitation, message)
end
@survey), notice: 'Invitation successfully sent'
redirect_to survey_path(else
@recipients = recipients
@message = message
'new'
render end
end
private
def valid_recipients?
.empty?
invalid_recipientsend
def valid_message?
.present?
messageend
def invalid_recipients
@invalid_recipients ||= recipient_list.map do |item|
unless item.match(EMAIL_REGEX)
itemend
end.compact
end
def recipient_list
@recipient_list ||= recipients.gsub(/\s+/, '').split(/[\n,;]+/)
end
def recipients
[:invitation][:recipients]
paramsend
def message
[:invitation][:message]
paramsend
end
Let’s extract all of the non-controller logic into a new class. We’ll start by defining and instantiating a new, empty class:
# app/controllers/invitations_controller.rb
@survey_inviter = SurveyInviter.new
# app/models/survey_inviter.rb
class SurveyInviter
end
At this point, we’ve created a staging area for using move method to transfer complexity from one class to the other.
Next, we’ll move one method from the controller to our new class. It’s best to move methods which depend on few private methods or instance variables from the original class, so we’ll start with a method which only uses one private method:
# app/models/survey_inviter.rb
def recipient_list
@recipient_list ||= @recipients.gsub(/\s+/, '').split(/[\n,;]+/)
end
We need the recipients for this method, so we’ll accept it in the
initialize
method:
# app/models/survey_inviter.rb
def initialize(recipients)
@recipients = recipients
end
And pass it from our controller:
# app/controllers/invitations_controller.rb
@survey_inviter = SurveyInviter.new(recipients)
The original controller method can delegate to the extracted method:
# app/controllers/invitations_controller.rb
def recipient_list
@survey_inviter.recipient_list
end
We’ve moved a little complexity out of our controller and we now have a repeatable process for doing so: We can continue to move methods out until we feel good about what’s left in the controller.
Next, let’s move out invalid_recipients
from the
controller, since it depends on recipient_list
, which we’ve
already moved:
# app/models/survey_inviter.rb
def invalid_recipients
@invalid_recipients ||= recipient_list.map do |item|
unless item.match(EMAIL_REGEX)
itemend
end.compact
end
Again, the original controller method can delegate:
# app/controllers/invitations_controller.rb
def invalid_recipients
@survey_inviter.invalid_recipients
end
This method references a constant from the controller. This was the only place where the constant was used, so we can move it to our new class:
# app/models/survey_inviter.rb
EMAIL_REGEX = /\A([^@\s]+)@((?:[-a-z0-9]+\.)+[a-z]{2,})\z/
We can remove an instance variable in the controller by invoking this method directly in the view:
# app/views/invitations/new.html.erb<% if @survey_inviter.invalid_recipients %>
<div class="error">
Invalid email addresses: <%= @survey_inviter.invalid_recipients.join(', ') %>
</div>
<% end %>
Now that parsing email lists is moved
out of our controller, let’s extract and delegate the only method in
the controller that depends on invalid_recipients
:
# app/models/survey_inviter.rb
def valid_recipients?
.empty?
invalid_recipientsend
Now we can remove invalid_recipients
from the controller
entirely.
The valid_recipients?
method is only used in the
compound validation condition:
# app/controllers/invitations_controller.rb
if valid_recipients? && valid_message?
If we extract valid_message?
as well, we can fully
encapsulate validation within SurveyInviter
.
# app/models/survey_inviter.rb
def valid_message?
@message.present?
end
We need message
for this method, so we’ll add that to
initialize
:
# app/models/survey_inviter.rb
def initialize(message, recipients)
@message = message
@recipients = recipients
end
And pass it in:
# app/controllers/invitations_controller.rb
@survey_inviter = SurveyInviter.new(message, recipients)
We can now extract a method to encapsulate this compound condition:
# app/models/survey_inviter.rb
def valid?
&& valid_recipients?
valid_message? end
And use that new method in our controller:
# app/controllers/invitations_controller.rb
if @survey_inviter.valid?
Now these methods can be private, trimming down the public interface
for SurveyInviter
:
# app/models/survey_inviter.rb
private
def valid_message?
@message.present?
end
def valid_recipients?
.empty?
invalid_recipientsend
We’ve pulled out most of the private methods, so the remaining complexity results largely from saving and delivering the invitations.
Let’s extract and move a deliver
method for that:
# app/models/survey_inviter.rb
def deliver
.each do |email|
recipient_list= Invitation.create(
invitation survey: @survey,
sender: @sender,
recipient_email: email,
status: 'pending'
)Mailer.invitation_notification(invitation, @message)
end
end
We need the sender (the currently signed-in user) as well as the survey from the controller to do this. This pushes our initialize method up to four parameters, so let’s switch to a hash:
# app/models/survey_inviter.rb
def initialize(attributes = {})
@survey = attributes[:survey]
@message = attributes[:message] || ''
@recipients = attributes[:recipients] || ''
@sender = attributes[:sender]
end
And extract a method in our controller to build it:
# app/controllers/invitations_controller.rb
def survey_inviter_attributes
[:invitation].merge(survey: @survey, sender: current_user)
paramsend
Now we can invoke this method in our controller:
# app/controllers/invitations_controller.rb
if @survey_inviter.valid?
@survey_inviter.deliver
@survey), notice: 'Invitation successfully sent'
redirect_to survey_path(else
@recipients = recipients
@message = message
'new'
render end
The recipient_list
method is now only used internally in
SurveyInviter
, so let’s make it private.
We’ve moved
most of the behavior out of the controller, but we’re still
assigning a number of instance variables for the view, which have
corresponding private methods in the controller. These values are also
available on SurveyInviter
, which is already assigned to
the view, so let’s expose those using attr_reader
:
# app/models/survey_inviter.rb
attr_reader :message, :recipients, :survey
And use them directly from the view:
# app/views/invitations/new.html.erb<%= simple_form_for(
:invitation,
url: survey_invitations_path(@survey_inviter.survey)
) do |f| %>
<%= f.input(
:message,
as: :text,
input_html: { value: @survey_inviter.message }
) %>
<% if @invlid_message %>
<div class="error">Please provide a message</div>
<% end %>
<%= f.input(
:recipients,
as: :text,
input_html: { value: @survey_inviter.recipients }
) %>
Only the SurveyInviter
is used in the controller now, so
we can remove
the remaining instance variables and private methods.
Our controller is now much simpler:
# app/controllers/invitations_controller.rb
class InvitationsController < ApplicationController
def new
@survey_inviter = SurveyInviter.new(survey: survey)
end
def create
@survey_inviter = SurveyInviter.new(survey_inviter_attributes)
if @survey_inviter.valid?
@survey_inviter.deliver
notice: 'Invitation successfully sent'
redirect_to survey_path(survey), else
'new'
render end
end
private
def survey_inviter_attributes
[:invitation].merge(survey: survey, sender: current_user)
paramsend
def survey
Survey.find(params[:survey_id])
end
end
It only assigns one instance variable, it doesn’t have too many methods and all of its methods are fairly small.
The newly extracted SurveyInviter
class absorbed much of
the complexity, but still isn’t as bad as the original controller:
# app/models/survey_inviter.rb
class SurveyInviter
EMAIL_REGEX = /\A([^@\s]+)@((?:[-a-z0-9]+\.)+[a-z]{2,})\z/
def initialize(attributes = {})
@survey = attributes[:survey]
@message = attributes[:message] || ''
@recipients = attributes[:recipients] || ''
@sender = attributes[:sender]
end
attr_reader :message, :recipients, :survey
def valid?
&& valid_recipients?
valid_message? end
def deliver
.each do |email|
recipient_list= Invitation.create(
invitation survey: @survey,
sender: @sender,
recipient_email: email,
status: 'pending'
)Mailer.invitation_notification(invitation, @message)
end
end
def invalid_recipients
@invalid_recipients ||= recipient_list.map do |item|
unless item.match(EMAIL_REGEX)
itemend
end.compact
end
private
def valid_message?
@message.present?
end
def valid_recipients?
.empty?
invalid_recipientsend
def recipient_list
@recipient_list ||= @recipients.gsub(/\s+/, '').split(/[\n,;]+/)
end
end
We can take this further by extracting more classes from
SurveyInviter
. See our full
solution on GitHub.
Drawbacks
Extracting classes decreases the amount of complexity in each class, but increases the overall complexity of the application. Extracting too many classes will create a maze of indirection that developers will be unable to navigate.
Every class also requires a name. Introducing new names can help to explain functionality at a higher level and facilitate communication between developers. However, introducing too many names results in vocabulary overload, which makes the system difficult to learn for new developers.
If you extract classes in response to pain and resistance, you’ll end up with just the right number of classes and names.
Next Steps
- Check the newly extracted class to make sure it isn’t a large class, and extract another class if it is.
- Check the original class for feature envy of the extracted class and use move method if necessary.
Ruby Science
The canonical reference for writing fantastic Rails applications from authors who have created hundreds.