Adopt new conventions to choose your compression level
For Rails apps to scale conceptually, it's often important to step off the so-called Rails Way and layer on new patterns.
This can make your app feel more Rails-like by doubling down on convention-over-configuration.
Let's look at a live example from Action Mailbox where we'll slim three files down to one and cut the lines of code too. We're using my ActiveRecord::AssociatedObject and ActiveJob::Performs gems to do so.
With these, we'll be getting more bang-for-our-code-buck, but the best part is that these conventions are just as easy to opt-out of if they're not a fit. We'll look at that too.
Let's dive in with a bit of context.
Action Mailbox's incineration
Action Mailbox handles routing and processing emails going into your Rails app. Internally, new emails are stored as ActionMailbox::InboundEmail
Active Record models.
By default, Action Mailbox destroys those records after 30 days. It also calls that delayed destroy handling incineration.
Here's how it's implemented.
First, ActionMailbox::InboundEmail
includes the Incineratable
concern:
# actionmailbox/app/models/action_mailbox/inbound_email.rb
class InboundEmail < Record
include Incineratable, …
Next, here's the Incineratable
concern. It sets up a callback to enqueue an Incineration Job once the InboundEmail
has been processed. It will eventually call incinerate
here, which then uses a Plain-Old-Ruby-Object (PORO) to perform the operation.
# actionmailbox/app/models/action_mailbox/inbound_email/incineratable.rb
# Ensure that the +InboundEmail+ is automatically scheduled for later incineration if the status has been
# changed to +processed+. The later incineration will be invoked at the time specified by the
# +ActionMailbox.incinerate_after+ time using the +IncinerationJob+.
module ActionMailbox::InboundEmail::Incineratable
extend ActiveSupport::Concern
included do
after_update_commit :incinerate_later, if: -> { ActionMailbox.incinerate && status_previously_changed? && processed? }
end
def incinerate_later
ActionMailbox::IncinerationJob.schedule self
end
def incinerate
Incineration.new(self).run
end
end
Here's the job. It sets up some specific configuration and then calls that incinerate
method:
# actionmailbox/app/jobs/action_mailbox/incineration_job.rb
module ActionMailbox
# You can configure when this +IncinerationJob+ will be run as a time-after-processing using the
# +config.action_mailbox.incinerate_after+ or +ActionMailbox.incinerate_after+ setting.
#
# Since this incineration is set for the future, it'll automatically ignore any InboundEmails
# that have already been deleted and discard itself if so.
#
# You can disable incinerating processed emails by setting +config.action_mailbox.incinerate+ or
# +ActionMailbox.incinerate+ to +false+.
class IncinerationJob < ActiveJob::Base
queue_as { ActionMailbox.queues[:incineration] }
discard_on ActiveRecord::RecordNotFound
def self.schedule(inbound_email)
set(wait: ActionMailbox.incinerate_after).perform_later(inbound_email)
end
def perform(inbound_email)
inbound_email.incinerate
end
end
end
Finally, the Incineration
PORO. It'll destroy the inbound email if some conditions pass:
# actionmailbox/app/models/action_mailbox/inbound_email/incineratable/incineration.rb
module ActionMailbox
# Command class for carrying out the actual incineration of the +InboundMail+ that's been scheduled
# for removal. Before the incineration – which really is just a call to +#destroy!+ – is run, we verify
# that it's both eligible (by virtue of having already been processed) and time to do so (that is,
# the +InboundEmail+ was processed after the +incinerate_after+ time).
class InboundEmail::Incineratable::Incineration
def initialize(inbound_email)
@inbound_email = inbound_email
end
def run
@inbound_email.destroy! if due? && processed?
end
private
def due?
@inbound_email.updated_at < ActionMailbox.incinerate_after.ago.end_of_day
end
def processed?
@inbound_email.processed?
end
end
end
There's a fair bit of boilerplate going on here. We've got three files with not a ton of code in them.
That can be fine, particularly if they grow in the future.
However, there's ways we could compress this incineration concept down further.
First Compression Level: Converting Incineration to an Associated Object
Associated Objects are a domain concept I've been working on. Basically they're POROs that are required to associate with an Active Record namespace and take that record as it's only argument. See the README for more in-depth information and examples.
The Incineration
PORO is pretty close to an Associated Object already, we just need to move it out to ActionMailbox::InboundEmail
. The initialize
method is the same as what AssociatedObject
generates so we can remove that too. Then, we can use the generated inbound_email
method instead of the instance variable. So now we've got:
# actionmailbox/app/models/action_mailbox/inbound_email/incineration.rb
module ActionMailbox
class InboundEmail::Incineration < ActiveRecord::AssociatedObject
def incinerate
inbound_email.destroy! if due? && processed?
end
private
def due?
inbound_email.updated_at < ActionMailbox.incinerate_after.ago.end_of_day
end
def processed?
inbound_email.processed?
end
end
end
Back in Incineratable
, we can reference it:
# actionmailbox/app/models/action_mailbox/inbound_email/incineratable.rb
module ActionMailbox::InboundEmail::Incineratable
extend ActiveSupport::Concern
included do
after_update_commit :incinerate_later, if: -> { ActionMailbox.incinerate && status_previously_changed? && processed? }
end
def incinerate_later
ActionMailbox::IncinerationJob.schedule self
end
def incinerate
ActionMailbox::InboundEmail::Incineration.new(self).run
end
end
It reads a little clunky because of the new namespace, so let's get rid of that.
Because we can compress it further by using Associated Object's has_object
in the Incineratable
concern:
included do
after_update_commit :incinerate_later, if: -> { ActionMailbox.incinerate && status_previously_changed? && processed? }
has_object :incineration
delegate :incinerate, to: :incineration
end
I've also renamed run
to incinerate
here.
Second Compression Level: inlining the Incineratable
Concern
Once you're starting to move more logic into the PORO, the wrapping concern starts making less and less sense as the integration place. Additionally, what if you could have one file to check instead of two? Let's do that!
First, we remove the include Incineratable
by moving the has_object :incineration
into InboundEmail
.
class InboundEmail < Record
include # …
has_object :incineration
end
With this we can take advantage of ActiveRecord::AssociatedObject
's extension
method to inline the wrapping concern:
class InboundEmail::Incineration < ActiveRecord::AssociatedObject
extension do
after_update_commit :incinerate_later, if: -> { ActionMailbox.incinerate && status_previously_changed? && processed? }
def incinerate_later
ActionMailbox::IncinerationJob.schedule self
end
delegate :incinerate, to: :incineration
end
end
Notice how we don't need the extend ActiveSupport::Concern
and included do
boilerplate? We also wouldn't have needed class_methods do
if there had been any of those.
That's because extension
is really just syntactic sugar for InboundEmail.class_eval
and we can spare some conceptual overhead by not involving Ruby's module hierarchy here. Sometimes you do need that, but for the common case? I'm starting to doubt it.
Bonus Compression: moving job logic out of Active Records and into Associated Objects
A really sweet feature in ActiveRecord::AssociatedObject
is that you can pass the Associated Object POROs to jobs!
First, we can move the incinerate_later
definition into the Incineration
. We're taking advantage of polymorphism here, since the IncinerationJob
just expects the passed argument to respond to incinerate
.
# Technically it's not an `inbound_email`, but we'll get to that later.
def perform(inbound_email) = inbound_email.incinerate
Second, we can now replace the incinerate
delegation with an incinerate_later
one, slimming the extension
a little further too.
class InboundEmail::Incineration < ActiveRecord::AssociatedObject
extension do
after_update_commit :incinerate_later, if: -> { ActionMailbox.incinerate && status_previously_changed? && processed? }
delegate :incinerate_later, to: :incineration
end
def incinerate_later
ActionMailbox::IncinerationJob.schedule self
end
end
Third Compression Level: removing the job boilerplate with ActiveJob::Performs
Finally, we've got the IncinerationJob
in its own file, but with the performs
method from ActiveJob::Performs
we can inline all of that too. You can see the README here for more details and examples.
So performs
metaprograms an Active Job class with the passed configuration and the incinerate_later
wrapping method automatically.
class InboundEmail::Incineration < ActiveRecord::AssociatedObject
extension do
after_update_commit :incinerate_later, if: -> { ActionMailbox.incinerate && status_previously_changed? && processed? }
delegate :incinerate_later, to: :incineration
end
performs :incinerate, discard_on: ActiveRecord::RecordNotFound, wait: ActionMailbox.incinerate_after,
queue_as: -> { ActionMailbox.queues[:incineration] }
def incinerate
inbound_email.destroy! if due? && processed?
end
end
Under the hood, performs
metaprograms a ActionMailbox::InboundEmail::Incineration::IncinerateJob
.
Here's what the metaprogrammed code looks like:
class IncinerateJob < ApplicationJob
discard_on ActiveRecord::RecordNotFound
wait ActionMailbox.incinerate_after # `wait` is an extension from `ActiveJob::Performs` so we don't need `schedule`.
queue_as -> { ActionMailbox.queues[:incineration] }
def perform(incineration) = incineration.incinerate
end
We're leveraging another convention here: that _later
methods wrap a job that only takes the PORO as the argument and calls the non-_later
method on that when performed.
performs
works on both Associated Objects and Active Records out of the box. Call extend ActiveJob::Performs
on any object that has include GlobalID::Identification
to use performs
there too.
Our new compressed incineration
Here's our final incineration model:
# actionmailbox/app/models/action_mailbox/inbound_email/incineration.rb
module ActionMailbox
class InboundEmail::Incineration < ActiveRecord::AssociatedObject
extension do
after_update_commit :incinerate_later, if: -> { ActionMailbox.incinerate && status_previously_changed? && processed? }
delegate :incinerate_later, to: :incineration
end
performs :incinerate, discard_on: ActiveRecord::RecordNotFound, wait: ActionMailbox.incinerate_after,
queue_as: -> { ActionMailbox.queues[:incineration] }
def incinerate
inbound_email.destroy! if due? && processed?
end
private
def due?
inbound_email.updated_at < ActionMailbox.incinerate_after.ago.end_of_day
end
def processed?
inbound_email.processed?
end
end
end
That brings the incineration feature down from 3 files to 1 and from 54 lines of code to 24 (that's around %65 less) while retaining the same functionality.
If you applied this in more places in your app, you'd have:
- Fewer files and less boilerplate and configuration to lug around
- Programmers having a tighter feedback loop when developing features
- Fewer lines of code where bugs can lurk (every bit counts)
- Compounding conceptual savings because the extra conventions mean you have to think less
- Knowing the conventions also make it easier to decompose new features when building them
Choose your compression level
One thing to note about these conventions I'm suggesting is that they're completely optional and easy to mix and match too.
If an Associated Object doesn't fit? Great, you can still reach for a Service Object!
You like the Associated Objects but that job generation seems a bit too far? Cool, you don't have to use that!
Your team can still choose what fits them best, if and when it fits. Enjoy, hope you liked this!
Want to have the Rails internals demystified?
Rails is your app's biggest dependency and you probably don't know what's going on in there.
The post you've just read came out of me walking through how Rails boots and loads engines and spotting these pieces when talking about Action Mailbox.
There's tons of things to learn from diving into Rails. I've basically built my career by leveling myself up from doing this.
I'm hosting a guided 2-hour tour in a few weeks. You can read about the event on Luma