July 2020
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.
Class Inheritance
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
Concerns
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 concerns
.
If you peak in your app
folder and then models
or controllers
, you’ll already see concerns
folders. We can create others if we want, perhaps in jobs
or channels
, and your computer won’t complain.
These 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 alerts.rb
in 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.
In our UsersController
:
class UsersController < ApplicationController
include Alerts
def controller_action
teacher_alert(@teacher)
end
end
or OthersController
:
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 alert.rb
inside models/concerns
and alert.rb
inside controllers/concerns
.
Cross Polination
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)
There’s an 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 concern
in controllers/concerns
and import the module into TeacherOfflineJob
as well as the ChatSessionsController
.
The session_actions.rb
concern:
# controllers/concerns
module SessionActions
def end_session_now(session)
# end the session
end
end
And the ChatSessionsController
:
class ChatSessionsController < ApplicationController
import SessionActions
def end_session
...
end_session_now(session)
...
end
end
And the TeacherOfflineJob
:
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 jobs
?
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 controllers
, models
, or whatever.
I opted against this approach for a few reasons:
1.
The 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.
2.
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 SessionActions
in concerns/controllers
and then importing into jobs
wherever necessary.
ActiveSupport
A useful part of Concerns, and something we haven’t even mentioned yet, is ActiveSupport
.
When we 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 models/concerns
:
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
And our User
model:
class User < ApplicationRecord
include UserUtilities
# User model stuff
end
By including ActiveSupport::Concern
, we have these two new blocks in our module: included do; end
and class_methods do; end
.
Included
The included
block allows us to specify callbacks that will have the correct scope when included in a model class. That’s why we put before_actions
and associations
in here. Basically the stuff that lives at the top of the model.
Instance methods
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
Inside the 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 included
block.
Conclusion
This is conclusion. Time for a break. Is it nice outside?