Sharing logic across a Rails app
There’s quite a bit of debate about coding best practices, but there is one thing that unites all programmers — Don’t Repeat Yourself (DRY).
This is also one of the rare paradigms that indulges human laziness while also improving code quality.
If there’s some functionality that needs to live in multiple places, let’s figure out a way to write it in one place, and just share it around.
There are a lot of ways for this sharing to occur, and some blog reading over the past couple days has left me with the sense that people are pretty uptight about this. Probably for good reason?
If you’re one of those people, feel free to email me and tell me why the workflow I’m about to run through is bad.
Sometimes we have a method that we want to share across all our controllers — let’s just plop it in our
ApplicationController and voila, all other controllers inherit from here via Ruby’s class inheritance:
class UsersController < ApplicationController
Same story if we want to share something across all our models — plop it in the
ApplicationRecord and all models will inherit:
class User < ApplicationRecord
Let’s say we have something that we want to share across just some of our models — does it make sense to make it accessible in all models? Probably not.
For this case, we can use
If you peak in your
app folder and then
controllers, you’ll already see
concerns folders. We can create others if we want, perhaps in
channels, and your computer won’t complain.
concerns folders are just homes for modules that contain the code you want to share.
To circle back to the original idea, we’re going down this path because we want to have greater control over where some shared code lives — with concerns, we can specify exactly where we want a module included.
For example, if we have a concern called
controllers/concerns that looks like:
module Alerts def teacher_alert(teacher) # do something when teachers come online end end
Wherever we want to reuse this logic, we can just include the module and have access to the methods that live within.
class UsersController < ApplicationController include Alerts def controller_action teacher_alert(@teacher) end end
class OthersController < ApplicationController include Alerts def other_controller_action teacher_alert(@teacher) end end
This seems fairly tidy, as we don’t have to fumble around with paths to make it work. This is because Rails automatically makes the modules that live inside of
concerns available for us to import.
This happens via Rails
autoload and the docs here state that Rails looks for:
Any existing second level directories called app/*/concerns in the application and engines.
So anything that lives inside a
concerns folder that’s a grandchild of
app is easily importable.
One consequence of this convenience is that the modules that live inside the
concerns folders are in the global namespace, so there will be problems if we have
In addition to including a module from
controllers/concerns in whichever controllers we choose, we can also include this same module outside of our controllers altogether.
For example, on Bounce, students and teachers chat, and either party can end a chat session whenever. (ending sessions is important because sessions are charged per minute)
end_session action in the
ChatSessionsController that handles this.
However, there are a handful of other scenarios where we need to end sessions. One example, if a teacher goes offline unexpectedly, we set a timer that eventually ends a session if a teacher doesn’t come back online. This timer is operated by a Job.
In this job,
TeacherOfflineJob, we want to reuse the same functionality as if a user clicked a button to end a session (which is in the controller).
So we abstract that logic into a
controllers/concerns and import the module into
TeacherOfflineJob as well as the
# controllers/concerns module SessionActions def end_session_now(session) # end the session end end
class ChatSessionsController < ApplicationController import SessionActions def end_session ... end_session_now(session) ... end end
class TeacherOfflineJob < ApplicationJob import SessionActions queue_as :default def perform(**args) ... end_session_now(session) ... end end
Is this the right place?
Now if you’re still with me, maybe you’re feeling like this is a bit hacky. We created a
concern in our
controllers, only to import it in our
One alternative would be to put this module in our
lib folder, which lives outside of our
app folder completely. In this case, the module is unassociated with
models, or whatever.
I opted against this approach for a few reasons:
lib folder is meant to house things unassociated with the core logic of your app — stuff that potentially could be extracted into a Gem to share with the world.
Ending chat sessions on Bounce is certainly not Gem-worthy — it’s pretty specific to this app.
Putting module in the
lib will require more explicit importing of modules, since Rails doesn’t immediately look there for things to include. We can fix this by specifying certain paths in our config to
autoload but this seems like a pain.
So overall I’m happy putting the module in the concern most closely associated with the functionality — i.e. putting the
concerns/controllers and then importing into
jobs wherever necessary.
A useful part of Concerns, and something we haven’t even mentioned yet, is
extend ActiveSupport::Concern in our concerns, we’re able to easily create Class methods and callbacks for the destination controllers or models.
Here we have a
UserUtilities module in
module UserUtilities extend ActiveSupport::Concern def instance_method # instance method stuff puts "i belong to an instance of user" end included do # callbacks like before_create / after_save after_create :setup_account # associations like has_one / belongs_to has_one :brain end class_methods do def class_method # class method stuff puts "welcome to my class" end end end
class User < ApplicationRecord include UserUtilities # User model stuff end
ActiveSupport::Concern, we have these two new blocks in our module:
included do; end and
class_methods do; end.
included block allows us to specify callbacks that will have the correct scope when included in a model class. That’s why we put
associations in here. Basically the stuff that lives at the top of the model.
The ordinary methods that live in the module will operate as instance methods when included in the model.
$ new_user = User.create # instance_method comes from concern $ new_user.instance_method $ => "i belong to an instance of user"
class_methods block that
ActiveSupport provides, we can create … Class Methods!
$ User.class_method $ => "welcome to my class"
This was all necessary because of scope. To create a class method in a model, we’d need to say something like
def self.class_method puts "hello class" end
But if we put that code in the module,
self would refer to the module, and not the class where it was included. This is why the
class_methods block helps us out.
Similar story for the
This is conclusion. Time for a break. Is it nice outside?