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

Connor Lay

,
Posted in Article Category: #Code
on