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:

  1. Create a new, empty class.
  2. Instantiate the new class from the original class.
  3. Move a method from the original class to the new class.
  4. 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[])
  end

  def create
    @survey = Survey.find(params[])

    @recipients = params[][]
    recipient_list = @recipients.gsub(/\s+/, '').split(/[\n,;]+/)

    @invalid_recipients = recipient_list.map do |item|
      unless item.match(EMAIL_REGEX)
        item
      end
    end.compact

    @message = params[][]

    if @invalid_recipients.empty? && @message.present?
      recipient_list.each do |email|
        invitation = Invitation.create(
          @survey,
          current_user,
          email,
          'pending'
        )
        Mailer.invitation_notification(invitation, @message)
      end

      redirect_to survey_path(@survey), 'Invitation successfully sent'
    else
      render 'new'
    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[])
  end

  def create
    @survey = Survey.find(params[])
    if valid_recipients? && valid_message?
      recipient_list.each do |email|
        invitation = Invitation.create(
          @survey,
          current_user,
          email,
          'pending'
        )
        Mailer.invitation_notification(invitation, message)
      end
      redirect_to survey_path(@survey), 'Invitation successfully sent'
    else
      @recipients = recipients
      @message = message
      render 'new'
    end
  end

  private

  def valid_recipients?
    invalid_recipients.empty?
  end

  def valid_message?
    message.present?
  end

  def invalid_recipients
    @invalid_recipients ||= recipient_list.map do |item|
      unless item.match(EMAIL_REGEX)
        item
      end
    end.compact
  end

  def recipient_list
    @recipient_list ||= recipients.gsub(/\s+/, '').split(/[\n,;]+/)
  end

  def recipients
    params[][]
  end

  def message
    params[][]
  end
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)
      item
    end
  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?
  invalid_recipients.empty?
end

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_message? && valid_recipients?
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?
  invalid_recipients.empty?
end

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
  recipient_list.each do |email|
    invitation = Invitation.create(
      @survey,
      @sender,
      email,
      '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[]
  @message = attributes[] || ''
  @recipients = attributes[] || ''
  @sender = attributes[]
end

And extract a method in our controller to build it:

# app/controllers/invitations_controller.rb
def survey_inviter_attributes
  params[].merge(@survey, current_user)
end

Now we can invoke this method in our controller:

# app/controllers/invitations_controller.rb
if @survey_inviter.valid?
  @survey_inviter.deliver
  redirect_to survey_path(@survey), 'Invitation successfully sent'
else
  @recipients = recipients
  @message = message
  render 'new'
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 , , 

And use them directly from the view:

# app/views/invitations/new.html.erb
<%= simple_form_for(
  ,
  url: survey_invitations_path(@survey_inviter.survey)
) do |f| %>
  <%= f.input(
    ,
    as: ,
    input_html: { value: @survey_inviter.message }
  ) %>
  <% if @invlid_message %>
    <div class="error">Please provide a message</div>
  <% end %>
  <%= f.input(
    ,
    as: ,
    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)
  end

  def create
    @survey_inviter = SurveyInviter.new(survey_inviter_attributes)
    if @survey_inviter.valid?
      @survey_inviter.deliver
      redirect_to survey_path(survey), 'Invitation successfully sent'
    else
      render 'new'
    end
  end

  private

  def survey_inviter_attributes
    params[].merge(survey, current_user)
  end

  def survey
    Survey.find(params[])
  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[]
    @message = attributes[] || ''
    @recipients = attributes[] || ''
    @sender = attributes[]
  end

  attr_reader , , 

  def valid?
    valid_message? && valid_recipients?
  end

  def deliver
    recipient_list.each do |email|
      invitation = Invitation.create(
        @survey,
        @sender,
        email,
        'pending'
      )
      Mailer.invitation_notification(invitation, @message)
    end
  end

  def invalid_recipients
    @invalid_recipients ||= recipient_list.map do |item|
      unless item.match(EMAIL_REGEX)
        item
      end
    end.compact
  end

  private

  def valid_message?
    @message.present?
  end

  def valid_recipients?
    invalid_recipients.empty?
  end

  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.

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