Skip to content

Instantly share code, notes, and snippets.

@ltk
Last active August 29, 2015 14:27
Show Gist options
  • Save ltk/3334489d4ebef803d613 to your computer and use it in GitHub Desktop.
Save ltk/3334489d4ebef803d613 to your computer and use it in GitHub Desktop.
Connor Lay Blog Post 08/2015

Slimming Down Your Models and Controllers with Concerns, Service Objects, and Tableless Models.

The Single Responsibility Principle

“A class should have one, and only one, reason to change.” - Uncle Bob

The single responsibility principle asserts that every class should have exactly one responsibility. In other words, each class should be concerned about one unique nugget of functionality, whether it be User, Post or InvitesController. The objects instantiated by these classes should be concerned with sending and responding to messages pertaining to their responsibility and nothing more.

Fat models, thin controllers

This is a common Rails mantra that a lot of tutorials, and thus a lot of beginners, follow when building their next application. While fat models are a little better than fat controllers, they still suffer from the same fundamental issues: when any one of the object's many responsibilities change, the object itself must change, resulting in those changes propagating throughout the app. Suddenly a minor tweak to a model broke half of your tests!

Benefits of following the single responsibility principle include (but are not limited to):

  • DRYer code: when every bit of functionality has been encapsulated into its own object, you find yourself repeating code a lot less.
  • Change is easy: cohesive, loosely coupled objects embrace change since they don’t know or care about anything else. Changes to User don’t impact Post at all since Post doesn’t even know that User exists.
  • Focused unit tests: instead of orchestrating a twisting web of dependencies just to setup your tests, objects with a single responsibility can easily be unit tested, taking advantage of doubles, mocks, and stubs to prevent your tests from breaking nearly as often.

No object should be omnipotent, including models and controllers. Just because a vanilla Rails 4 app directory contains models, views, controllers, and helpers does not mean you are restricted to those four domains.

There are dozens of design patterns out there to address the single responsibility principle in Rails. I'm going to talk about the few that I explored this summer.

Encapsulating model roles with concerns

Imagine you're building a simple online news site similar to Reddit or Hacker News. The main interaction a user will have with the app is submitting and voting on posts.

class Post < ActiveRecord::Base
  validates :title, :content, presence: true

  has_many :votes
  has_many :comments

  def vote!
    votes.create
  end
end

class Comment < ActiveRecord::Base
  validates :content, presence: :true

  belongs_to :post
end

class Vote < ActiveRecord::Base
  belongs_to :post
end

So far so good.

Now imagine that you want users to be able to vote on both posts and comments. You decided to implement a basic polymorphic association and end up with this:

class Post < ActiveRecord::Base
  validates :title, :content, presence: true

  has_many :votes, as: :votable
  has_many :comments

  def vote!
    votes.create
  end
end

class Comment < ActiveRecord::Base
  validates :content, presence: :true

  has_many   :votes, as: :votable
  belongs_to :post

  def vote!
    votes.create
  end
end

class Vote < ActiveRecord::Base
  belongs_to :votable, polymorphic: true
end

Uh oh. You already have some duplicated code with #vote!. To make matters worse you now want to have both upvotes and downvotes.

class Vote < ActiveRecord::Base
  enum type: [:upvote, :downvote]

  validates :type, presence: true

  belongs_to :votable, polymorphic: true
end

Vote's API has changed, as .new and .create now require a type argument. In the small case of just posts and comments, this is not too big of a change. But what happens if you have 10 models that can be voted on? 100?

Enter ActiveModel::Concern

module Votable
  extend ActiveModel::Concern

  included do
    has_many :votes, as: :votable
  end

  def upvote!
    votes.create(type: :upvote)
  end

  def downvote!
    votes.create(type: :downvote)
  end
end

class Post < ActiveRecord::Base
  include Votable

  validates :title, :content, presence: true

  has_many :comments
end

class Comment < ActiveRecord::Base
  include Votable

  validates :content, presence: :true

  belongs_to :post
end

Concerns are essentially modules that allow you to encapsulate model roles into separate files to DRY up your code. In our example, Post and Comment both fulfill the role of votable, so they include the Votable concern to access that shared behavior. Concerns are great at organizing the various roles played by your models. However, concerns are not the solution to a model with too many responsibilities.

Below is an example of a concern that still breaks the single-responsibility principle.

module Mailable
  extend ActiveModel::Conern

  def send_password_reset_email
    UserMailer.password_reset(self).deliver_now
  end

  def send_confirmation_email
    UserMailer.confirmation(self).deliver_now
  end
end

This problem is not something concerns are good at solving. A User model should not know about UserMailer. While the actual user.rb file does not contain any reference to UserMailer, the User class does.

Concerns are a great tool for sharing behavior between models, but must be used responsibly and with a clear intent.

Reducing controller complexity with Service objects

On the topic of emails, lets take a look at controllers. Imagine we want users to be able to invite their friends by submitting a list of emails. Whenever an email is invited, a new Invite object is created to keep track of who has already been invited. Any invalid emails are rendered in the flash with an error message.

class InvitesController < ApplicationController
  def new
  end

  def create
    emails.each do |email|
      if Invite.new(email).save
        UserMailer.invite(email).deliver_now
      else
        invalid_emails << email
      end
    end
    unless invalid_emails.empty?
      flash[:danger] = "The following emails were not invited: #{invalid_emails}"
    end
    redirect_to :new
  end

  private

  def emails
    params.require(:invite).permit(:emails)[:emails]
  end

  def invalid_emails
    @invalid_emails ||= []
  end
end

What exactly is wrong with this code you might ask? The single responsibility of a controller is to accept HTTP requests and respond with data. In the above code, sending an invite to a list of emails is an example of business logic that does not belong in the controller. Unit testing sending invites is impossible because this feature is so tightly coupled to InvitesController. You might consider putting this logic into the Invite model, but that's not much better. What would happen if you wanted similar behavior in another part of the application that was not tied to any particular model or controller?

Fortunately there is a solution! Many times specific business logic like sending emails in bulk can be encapsulated into a plain old ruby object (affectionately known as POROs). These objects, often referred to as Service or Interaction objects, accept input, perform work, and return a result. For complex interactions that involve creating and destroying multiple records of different models, service objects are a great way to encapsulate that responsibility out of the models, controllers, views, and helpers framework Rails provides by default.

class BulkInviter
  attr_reader :invalid_emails

  def initialize(emails)
    @emails = emails
    @invalid_emails = []
  end

  def perform
    emails.each do |email|
      if Invite.new(email).save
        UserMailer.invite(email).deliver_now
      else
        invalid_emails << email
      end
    end
  end
end

class InvitesController < ApplicationController
  def new
  end

  def create
    inviter = BulkInviter.new(emails)
    inviter.perform
    unless inviter.invalid_emails.empty?
      flash[:danger] = "The following emails were not invited: #{inviter.invalid_emails}"
    end
    redirect_to :new
  end

  private

  def emails
    params.require(:invite).permit(:emails)[:emails]
  end

  def invalid_emails
    @invalid_emails ||= []
  end
end

In this example the responsibility of sending emails in bulk has been moved out of the controller and into a service object called BulkInviter. InvitesController does not know or care how exactly BulkInviter accomplishes this; all it does is ask BulkInviter to perform its job. While much better than the fat controller version, there is still room for improvement. Notice how InvitesController still needs to know that BulkInviter has a list of invalid emails? That additional dependency further couples InvitesController to BulkInviter.

One solution is to wrap all output from service objects into a Response object.

class Response
  attr_reader :data, :message

  def initialize(data, message)
    @data    = data
    @message = message
  end

  def success?
    raise NotImplementedError
  end
end

class Success < Response
  def success?
    true
  end
end

class Error < Response
  def success?
    false
  end
end

class BulkInviter

  def initialize(emails)
    @emails = emails
  end

  def perform
    emails.each do |email|
      if Invite.new(email).save
        UserMailer.invite(email).deliver_now
      else
        invalid_emails << email
      end
    end
    if invalid_emails.empty?
      Success.new(emails, "Emails invited!")
    else
      Error.new(invalid_emails, "The following emails are invalid: #{invalid_emails}")
    end
  end
end

class InvitesController < ApplicationController
  def new
  end

  def create
    inviter = BulkInviter.new(emails)
    response = inviter.perform
    if response.success?
      flash[:success] = response.message
    else
      flash[:danger] = response.message
    end
    redirect_to :new
  end

  private

  def emails
    params.require(:invite).permit(:emails)[:emails]
  end
end

Now InvitesController is truly ignorant of how BulkInviter works; all it does is ask for BulkInviter to do some work and sends the response off to the view.

Service objects are a breeze to unit test, easy to change, and can be reused as your app grows. However, like any design pattern, service objects have an associated cost. Abusing the service object design pattern often results in tightly coupled objects that feel more like shifting methods around and less like following the single responsibility principle. More objects also means more complexity and finding the exact location of a particular feature involves digging through a services directory.

The biggest challenge I faced when designing service objects is defining an intuitive API that easily communicates the responsibility of the object. One approach is to treat these objects like procs or lambdas, implementing a #call or #perform method that performs work. While this is great for standardizing the interface across service objects, it heavily relies on descriptive class names to communicate the object's responsibility.

One idea I've used to further communicate the purpose of service objects is to namespacing them into their specific domain:

Invites::BulkInviter
Comments::Creator
Votes::Aggregator

The exact implementation of these service objects is largely style based and depends on the complexity of your business logic.

Taking advantage of Active Record Model

The last topic I want to cover is the idea of tableless models. Starting in Rails 4, you can include ActiveModel::Model to allow an object to interface with Action Pack, gaining the full interface that Active Record models enjoy. Objects that include ActiveModel::Model are not persisted in the database, but can be instantiated with attribute assignment, validated with built in validations, and have forms generated with form helpers, and much more!

When would you make a tableless model? Let's look at an example!

Imagine we are building an online password strength checker. There are many characteristics that a good password should have, such as an 8 character minimum and a combination of uppercase and lowercase letters. Since these passwords have no use elsewhere in our app, we don't want to persist them in the database.

Our first attempt might involve some kind of service object.

class PasswordStrengthController < ApplicationController
  def new
  end

  def create
    checker = PasswordChecker.new(string: params[:string])
    if checker.perform
      flash.now[:success] = "That is a strong password."
    else
      flash.now[:danger] = "That is a weak password."
    end
    render :new
  end
end

class PasswordChecker
  attr_reader :string

  def initialize(string:)
    @string = string
  end

  def perform
    length?(8)             &&
    contains_nonword_char? &&
    contains_uppercase?    &&
    contains_lowercase?    &&
    contains_digits?
  end

  private

  def length?(n)
    password.length >= n
  end

  def contains_nonword_char
    password.match(/.*\W.*/)
  end

  def contains_uppercase
    password.match(/.*[A-Z].*/)
  end

  def contains_lowercase
    password.match(/.*[a-z].*/)
  end

  def contains_digits
    password.match(/.*[0-9].*/)
  end
end

While this works, something feels off about our new PasswordChecker service object. It's not interacting with any models and it does not change any states. The service object API is awkward as it is not clear if #perform is a query or a command. If we take a step back and think about what exactly this service object's responsibility is, we soon arrive at validating the strength of a password. In other words, PasswordChecker contains and validates a set of data, much like Active Record models do.

This is a great case for tableless models!

class PasswordCheck
  include ActiveModel::Model

  attr_accessor :string

  validates :string, length: { minimum: 8 }
  validates :string, format: { with: /.*\W.*/,    message: "must contain nonword chars"     }
  validates :string, format: { with: /.*[A-Z].*/, message: "must contain uppercase letters" }
  validates :string, format: { with: /.*[a-z].*/, message: "must contain lowercase letters" }
  validates :string, format: { with: /.*[0-9].*/, message: "must contain digits"            }
end

class PasswordStrengthController < ApplicationController
  def new
    @password = PasswordCheck.new
  end

  def create
    @password = PasswordCheck.new(string: params[:string])
    if @password.valid?
      flash.now[:success] = "That is a strong password."
    else
      flash.now[:danger] = "That is a weak password."
    end
    render :new
  end
end

Not only do we get the power of built in validations, it becomes much easier to render error messages and generate the form for submitting a new password.


Concerns, service objects, and tableless models are all excellent ways to fight growing pains experienced when building a Rails application. It is important not to force design patterns, but to discover them while building your application. In many scenarios, DRYing up model roles with Concerns, creating a service object layer between your models and controllers, or encapsulating temporary data into tableless models makes a lot of sense. Other times it smells a lot like premature optimization and is not the best approach.

As with everything programming, the best way to learn is to get your hands dirty!

Further Reading

@ltk
Copy link
Author

ltk commented Aug 20, 2015

Really nice post. 👍 I only have three tiny comments:

  • typo: BulkInivter
  • virtual models are probably more accurately described as tableless models
  • I'd add a visual break before the last two concluding paragraphs. ~~~ works well if you're writing in markdown I believe.

@dce
Copy link

dce commented Aug 21, 2015

Period after "reason to change" in intro, and link to http://butunclebob.com/ArticleS.UncleBob.SrpInRuby.

I'd probably link to http://weblog.jamisbuck.org/2006/10/18/skinny-controller-fat-model for fat models.

No period for "Encapsulating model roles with concerns."

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment