Ruby Science

Open/Closed Principle

The Open/Closed Principle states that:

Software entities (classes, modules, functions, etc.) should be open for extension, but closed for modification.

The purpose of this principle is to make it possible to change or extend the behavior of an existing class without actually modifying the source code to that class.

Making classes extensible in this way has a number of benefits:

  • Every time you modify a class, you risk breaking it, along with all classes that depend on that class. Reducing churn in a class reduces bugs in that class.
  • Changing the behavior or interface to a class means that you need to update any classes that depend on the old behavior or interface. Allowing per-use extensions to a class eliminates this domino effect.

Strategies

It may sound appealing to never need to change existing classes again, but achieving this is difficult in practice. Once you’ve identified an area that keeps changing, there are a few strategies you can use to make it possible to extend without modifications. Let’s go through an example with a few of those strategies.

In our example application, we have an Invitation class that can deliver itself to an invited user:

# app/models/invitation.rb
def deliver
  body = InvitationMessage.new(self).body
  Mailer.invitation_notification(self, body).deliver
end

However, we need a way to allow users to unsubscribe from these notifications. We have an Unsubscribe model that holds the email addresses of users that don’t want to be notified.

The most direct way to add this check is to modify Invitation directly:

# app/models/invitation.rb
def deliver
  unless unsubscribed?
    body = InvitationMessage.new(self).body
    Mailer.invitation_notification(self, body).deliver
  end
end

However, that would violate the open/closed principle. Let’s see how we can introduce this change without violating the principle.

Inheritance

One of the most common ways to extend an existing class without modifying it is to create a new subclass.

We can use a new subclass to handle unsubscriptions:

# app/models/unsubscribeable_invitation.rb
class UnsubscribeableInvitation < Invitation
  def deliver
    unless unsubscribed?
      super
    end
  end

  private

  def unsubscribed?
    Unsubscribe.where(recipient_email).exists?
  end
end

This can be a little awkward when trying to use the new behavior, though. For example, we need to create an instance of this class, even though we want to save it to the same table as Invitation:

# app/models/survey_inviter.rb
def create_invitations
  Invitation.transaction do
    recipients.map do |recipient_email|
      UnsubscribeableInvitation.create!(
        survey,
        sender,
        recipient_email,
        'pending',
        @message
      )
    end
  end
end

This works adequately for creation, but using the ActiveRecord pattern, we’ll end up with an instance of Invitation instead, if we ever reload from the database. That means that inheritance is easiest to use when the class we’re extending doesn’t require persistence.

Inheritance also requires some creativity in unit tests to avoid duplication.

Decorators

Another way to extend an existing class is to write a decorator.

Using Ruby’s DelegateClass method, we can quickly create decorators:

# app/models/unsubscribeable_invitation.rb
class UnsubscribeableInvitation < DelegateClass(Invitation)
  def deliver
    unless unsubscribed?
      super
    end
  end

  private

  def unsubscribed?
    Unsubscribe.where(recipient_email).exists?
  end
end

The implementation is extremely similar to the subclass but it can now be applied at run-time to instances of Invitation:

# app/models/survey_inviter.rb
def deliver_invitations
  create_invitations.each do |invitation|
    UnsubscribeableInvitation.new(invitation).deliver
  end
end

The unit tests can also be greatly simplified using stubs.

This makes it easier to combine with persistence. However, Ruby’s DelegateClass doesn’t combine well with ActionPack’s polymorphic URLs.

Dependency Injection

This method requires more forethought in the class you want to extend, but classes that follow inversion of control can inject dependencies to extend classes without modifying them.

We can modify our Invitation class slightly to allow client classes to inject a mailer:

# app/models/invitation.rb
def deliver(mailer)
  body = InvitationMessage.new(self).body
  mailer.invitation_notification(self, body).deliver
end

Now we can write a mailer implementation that checks to see if users are unsubscribed before sending them messages:

# app/mailers/unsubscribeable_mailer.rb
class UnsubscribeableMailer
  def self.invitation_notification(invitation, body)
    if unsubscribed?(invitation)
      NullMessage.new
    else
      Mailer.invitation_notification(invitation, body)
    end
  end

  private

  def self.unsubscribed?(invitation)
    Unsubscribe.where(invitation.recipient_email).exists?
  end

  class NullMessage
    def deliver
    end
  end
end

And we can use dependency injection to substitute it:

# app/models/survey_inviter.rb
def deliver_invitations
  create_invitations.each do |invitation|
    invitation.deliver(UnsubscribeableMailer)
  end
end

Everything is Open

As you’ve followed along with these strategies, you’ve probably noticed that although we’ve found creative ways to avoid modifying Invitation, we’ve had to modify other classes. When you change or add behavior, you need to change or add it somewhere. You can design your code so that most new or changed behavior takes place by writing a new class, but something, somewhere in the existing code will need to reference that new class.

It’s difficult to determine what you should attempt to leave open when writing a class. It’s hard to know where to leave extension hooks without anticipating every feature you might ever want to write.

Rather than attempting to guess what will require extension in the future, pay attention as you modify existing code. After each modification, check to see if there’s a way you can refactor to make similar extensions possible without modifying the underlying class.

Code tends to change in the same ways over and over, so by making each change easy to apply as you need to make it, you’re making the next change easier.

Monkey Patching

As a Ruby developer, you probably know that one quick way to extend a class without changing its source code is to use a monkey patch:

# app/monkey_patches/invitation_with_unsubscribing.rb
Invitation.class_eval do
  alias_method , 

  def deliver
    unless unsubscribed?
      deliver_unconditionally
    end
  end

  private

  def unsubscribed?
    Unsubscribe.where(recipient_email).exists?
  end
end

Although monkey patching doesn’t literally modify the class’s source code, it does modify the existing class. That means that you risk breaking it, and, potentially, all classes that depend on it. Since you’re changing the original behavior, you’ll also need to update any client classes that depend on the old behavior.

In addition to all the drawbacks of directly modifying the original class, monkey patches also introduce confusion, as developers will need to look in multiple locations to understand the full definition of a class.

In short, monkey patching has most of the drawbacks of modifying the original class without any of the benefits of following the open/closed principle.

Drawbacks

Although following the open/closed principle will make code easier to change, it may make it more difficult to understand. This is because the gained flexibility requires introducing indirection and abstraction. Although all of the three strategies outlined in this chapter are more flexible than the original change, directly modifying the class is the easiest to understand.

This principle is most useful when applied to classes with high reuse and potentially high churn. Applying it everywhere will result in extra work and more obscure code.

Application

If you encounter the following smells in a class, you may want to begin following this principle:

You may want to eliminate the following smells if you’re having trouble following this principle:

  • Case statements make it hard to obey this principle, as you can’t add to the case statement without modifying it.

You can use the following solutions to make code more compliant with this principle:

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.