How Can You Do it? Well, It Prepends...
I have known about Ruby’s Module#prepend method since it was introduced in Ruby 2.0 over a decade ago but never quite found a need for it. Module#include is prepend
’s older and more famous sibling similarly useful for decoration and composition. I suspect that other developers, like me, naturally just lean on include
when abstracting shared methods to a module.
In fact, it is no surprise that I rarely find myself turning to this powerful feature since it goes largely neglected even in ActiveSupport::Concern
. Although you can clearly use a prepended block when using this most popular wrapper for including module methods in a class, it isn’t even mentioned in the introductory documentation examples. I suppose the functionality is so similar to include, why bother confusing yourself and other developers that have enough trouble figuring out when and how to include
vs extend
a Module
However, it really isn’t too difficult to understand the critical difference between include
and prepend
so long as you understand how Ruby integrates methods into its lookup chain. Whenever you call a method on an object it not only will look for instance methods in the class itself but will also check for any methods that may be defined in classes from its inheritance chain but also first checks for any modules that have been included (or prepended!). As explained by ChatGPT the main difference between include
and prepend
is that a prepended module will actually get inserted before the class that it is prepended to. It is a bit of a mind-bender to think about giving some other method in a module precedence over the class itself but Ruby likes to be flexible and give you every possible trick for your trade just in case.
I recently finally found myself in a situation where I wanted just such a trick. I was working on a project that was upgrading its Rails version to 6.0 and wanted to make use of ActiveRecord’s Multiple Databases support which is a great feature worthy of its own blog. The project had been using the active-record-slave gem but now wanted to use the built-in support for primary/replica databases provided by Rails.
The project ideally wanted many of their ActiveJob workers to point to the replica by default, especially when they handled read intensive jobs. Though ActiveRecord does not provide an automatic way to switch between reading and writing out of the box, it does at least throw an error when a write is called against a replica. So, it seemed like best practice to point to the replica by default and switch to writing for creates, updates and destroys.
This app was using Sidekiq as their background queue so I considered using a Middleware to add this default replica read functionality. However, in the end, there were a few jobs that just involved writing to the primary and it seemed messy to have to switch back to the default primary database in such cases. I wanted something more flexible to wrap perform
methods in a read block and that is when prepend
came to mind. This is the solution I came up with in app/jobs/concerns/replica_reading.rb
:
# frozen_string_literal: true
module ReplicaReading
def perform(*args)
read_from_replica { super }
end
end
In ApplicationJob
there is a call to
delegate :read_from_replica, :write_to_primary, to: ApplicationRecord
And in ApplicationRecord
:
class ApplicationRecord < ActiveRecord::Base
self.abstract_class = true
# ...
def self.read_from_replica(&block)
return block.call if ActiveRecord::Base.connected_to?(role: :reading)
Rails.logger.info('Switching to reading role')
result = ActiveRecord::Base.connected_to(role: :reading, &block)
Rails.logger.info('Returning to writing role')
result
end
def self.write_to_primary(&block)
return block.call if ActiveRecord::Base.connected_to?(role: :writing)
Rails.logger.info('Switching to writing role')
result = ActiveRecord::Base.connected_to(role: :writing, &block)
Rails.logger.info('Returning to reading role')
result
end
connects_to database: { writing: :primary, reading: :primary_replica }
end
Now any job simply needs to prepend ReplicaReading
and when ActiveJob calls perform
on a job it will first call our read_from_replica
wrapping method and the call to super
will actually call the perform
method defined in the job itself. Note how it is important to slurp in all *args
in the method signature so that they are passed along to the call to super
. Like I said, a bit mind bending, but effective.
Of course, it does mean that reading from the replica database is not quite so automatic because you need to remember the explicit prepend
call but in the end it was probably best to not hide that concern entirely. We still get the benefit on not having the actual perform
method in the job cluttered with an additional wrapping block while an incoming developer is hopefully reminded that the job will read from replica by default.
I suspect that there are many creative and helpful ways to make use of Ruby’s prepend
. Seeing a practical use of it helps make how it works more tangible and hopefully will inspire other clever ways to solve problems in your applications.
If you’re looking for a team to help you discover the right thing to build and help you build it, get in touch.
Published on September 4, 2024