kaspth

Subscribe
Archives
January 16, 2025

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, … 

See it on GitHub

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

See the file on GitHub

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

See the file on GitHub

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

See the file on GitHub

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

Post by @garrettdimon@ruby.social
View on Mastodon
Don't miss what's next. Subscribe to kaspth:
GitHub X
This email brought to you by Buttondown, the easiest way to start and grow your newsletter.