Refactoring Fat Rails models with Concerns

In this post, we’ll be taking a close look on how to avoid the proverbial ‘Ball of Mud’ in object-oriented design. Specifically, we’ll be refactoring fat Rails models using ActiveSupport::Concerns.

I highly recommend reading POODR for more in-depth refactoring techniques and proper OO design. It's helped me tremendously while working on SheetHub.

Sandi Metz, the author of Practical Object-Oriented Design in Ruby (POODR), has the following rules on how to write object-oriented code:

Sandi Metz’ four rules:

  • Your class can be no longer than 100 lines of code.
  • Your methods can be no longer than five lines of code.
  • You can pass no more than four parameters and you can’t just make it one big hash.
  • When a call comes into your Rails controller, you can only instantiate one object to do whatever it is that needs to be done. And your view can only know about one instance variable.

Before using concerns, I had models with more than 500 lines of code with specs that are equally as long. Whenever I had to support additional functionality, it takes me some time to even scroll down the model.rb file. There was no clear separation of concerns, and all of my methods just melded together haphazardly. It felt wrong. I thought, “There has to be a better way to organize related, reusable units of functionality!”

Enter Concerns

Different models in your Rails application will often share a set of reusable groups of methods. These groups can be extracted into modules; in Rails, concerns. ActiveSupport::Concern is a simple, safe method refactoring that is a default part of Rails, not an extra gem. It works for both models and controllers.

SheetHub for example, has concerns with names such as Flaggable, Taggable, Likable, and Trashable.

Below is an excerpt of SheetHub’s Flaggable module:

module Flaggable
  extend ActiveSupport::Concern

  included do
    has_many :flags, dependent: :destroy
    scope :flagged, -> { where(is_flagged: true) }
  end

  def flag(user, reason)
    Flag.create(entity: self, user:user, reason: reason)
  end

end

Concerns follow the following structure:

module ModelName::ConcernName
  extend ActiveSupport::Concern

  included do
    # class macros
  end

  # instance methods
  def some_instance_method

  end

  module ClassMethods
    # class methods here, self included
  end
end

Concerns such as Flaggable encapsulate both data access and domain logic about a certain slice of responsibility. Because concerns are polymorphic and reusable by design, any model that requires a set of functionalities can simply compose a set of modules into its model definition. Building objects through Composition over Inheritance (aka Composite Reuse Principle) lead to thinner models and higher cohesion. It results in a domain model that’s simple and easy to understand.

Furthermore, concerns are also helpful not just to encapsulate shared pieces of functionality, but also for extracting a subset of your model that doesn’t seem part of its core essence. For example, SheetHub’s Sheet music model includes a module to handle tracking/analytics which isn’t really core that what the model is about. We don’t want to run the risk of ballooning our models unnecessarily.

Now that we’ve seen the benefits of abstracting a set of related methods into concerns, how do we start to refactor?

Let’s start with your ActiveRecord model. If you haven’t made any deliberate refactoring efforts, it’s likely that it has more than 100 LOC. We want to break this ball of mud into separate concerns, each concern following the Single Responsibility Principle (SRP.)

Before we can do that, we must first discover sets of related methods by problem domain in your current model. In a sea of 50 methods, there will be almost certainly groupings based on domain.

For example, there will be methods common to all objects that can be Flagged. We want to extract that common functionality into a Flaggable concern which captures logic of flagging objects. All models that require that flagging functionality will simply include the Flaggable concern in its model definition:

class Sheet < ActiveRecord::Base
	include Flaggable
	include Taggable
	include Trashable

	# The rest of your code below...
end

Ensure that your concerns are polymorphic and reusable whenever possible. Using introspection accessors such as self.class and polymorphic associations goes a long way. Doing this properly will let you re-use your modules not only across models, but across Rails projects. Having a shared library of reusable modules does wonders on your development time.

Note that we will also need to break out our unit tests into concern-level tests. Instead of having a single spec for your models, you will have one spec each for different concerns. From my own experience, this has helped simplify testing efforts and has had a positive impact on debugging time.

To sum up, we’ve seen how to refactor fat Rails models using concerns. Bear in mind that this refactoring process must come naturally as the application evolves.

Do you even need to optimize? Is there an actual pain point, or just a speculated pain point? If it’s only speculation, don’t optimize. YAGNI it and work on more important functionality.

Only if there’s a real need, refactor. Don’t optimize prematurely. If you do it right, as your application grows you will find yourself writing less code thanks to your library of reusable concerns.

Additional Reading: