Eric / Brooklyn

Visual

Blog Index

Random 5

Stripe and Tap

Multi-tab Websockets

Ruby vs. Ruby

Cookies

Web Accounts

Emoji Bits

Honeypot Bots

Bad Content

JS Data Chutzpah

Sendgrid Ban

Clean URLs

Git

Concerns

Rails UJS

Extending Devise

ENV Variables

See All

Rails Concerns

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?