“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.
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 impactPost
at all sincePost
doesn’t even know thatUser
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.
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.
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.
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!
Really nice post. 👍 I only have three tiny comments:
BulkInivter
virtual models
are probably more accurately described astableless models
~~~
works well if you're writing in markdown I believe.