Rails 8 Module Prepending Patterns

When extending Rails internals or modifying gem behavior, developers often reach for alias_method_chain patterns or monkey patching. These approaches create brittle code that breaks across upgrades. Module prepending offers a cleaner, more maintainable solution that Ruby has supported since version 2.0—yet many Rails developers underutilize it.

The Problem with Traditional Overrides

Consider a common scenario: adding audit logging to Active Record's save method. The naive approach looks like this:

# config/initializers/bad_audit.rb (DON'T DO THIS)
class ActiveRecord::Base
  alias_method :original_save, :save
  
  def save(*args, **options)
    Rails.logger.info "Saving #{self.class.name}"
    original_save(*args, **options)
  end
end

This creates multiple problems: it pollutes the class with extra methods, breaks when other code uses the same pattern, and makes debugging a nightmare. Module prepending solves these issues elegantly.

How Prepend Works

When a module is prepended to a class, Ruby inserts it before the class in the method lookup chain. Calling super from the prepended module invokes the original class method. This creates a clean override without renaming or aliasing anything.

# config/initializers/audit_logging.rb
module AuditLogging
  def save(*args, **options)
    Rails.logger.info "[AUDIT] Saving #{self.class.name} id=#{id || 'new'}"
    result = super
    Rails.logger.info "[AUDIT] Save #{result ? 'succeeded' : 'failed'} for #{self.class.name}"
    result
  end
  
  def destroy
    Rails.logger.info "[AUDIT] Destroying #{self.class.name} id=#{id}"
    super
  end
end

ActiveSupport.on_load(:active_record) do
  prepend AuditLogging
end

The ActiveSupport.on_load hook ensures the prepend happens after Active Record fully loads, avoiding load order issues. The super call passes through to the original save method naturally.

Targeted Prepending for Specific Models

Global prepending affects every model. For targeted behavior, prepend to specific classes or use a concern pattern:

# app/models/concerns/publishable.rb
module Publishable
  extend ActiveSupport::Concern
  
  included do
    scope :published, -> { where.not(published_at: nil) }
    scope :draft, -> { where(published_at: nil) }
  end
  
  # This module gets prepended to override save behavior
  module SaveHooks
    def save(*args, **options)
      self.slug ||= title.parameterize if respond_to?(:slug) && respond_to?(:title)
      super
    end
  end
  
  class_methods do
    def inherited(subclass)
      subclass.prepend(SaveHooks)
      super
    end
  end
  
  included do
    prepend SaveHooks
  end
end

# app/models/article.rb
class Article < ApplicationRecord
  include Publishable
  
  validates :title, presence: true
  validates :slug, uniqueness: true
end

This pattern keeps the prepend logic encapsulated within the concern. Any model including Publishable automatically gets the slug generation behavior.

Prepending to Controller Actions

Prepend works equally well for extending controller behavior. Here's a pattern for adding request timing to specific controllers:

# app/controllers/concerns/request_timing.rb
module RequestTiming
  extend ActiveSupport::Concern
  
  module ProcessActionTiming
    def process_action(*args)
      start_time = Process.clock_gettime(Process::CLOCK_MONOTONIC)
      result = super
      duration = Process.clock_gettime(Process::CLOCK_MONOTONIC) - start_time
      
      response.headers['X-Request-Duration'] = "#{(duration * 1000).round(2)}ms"
      Rails.logger.info "[TIMING] #{controller_name}##{action_name} took #{(duration * 1000).round(2)}ms"
      
      result
    end
  end
  
  included do
    prepend ProcessActionTiming
  end
end

# app/controllers/api/v1/base_controller.rb
module Api
  module V1
    class BaseController < ApplicationController
      include RequestTiming
      
      skip_before_action :verify_authenticity_token
    end
  end
end

Every controller inheriting from Api::V1::BaseController now includes timing headers and logging without modifying action methods directly.

Extending Gem Behavior Safely

Prepending shines when modifying gem behavior without forking. Here's an example extending Solid Queue's job processing:

# config/initializers/solid_queue_extensions.rb
module SolidQueueJobExtensions
  def perform
    ActiveSupport::Notifications.instrument('solid_queue.job.perform', job: self.class.name) do
      super
    end
  rescue StandardError => e
    ErrorTracker.capture(e, context: { job_class: self.class.name, arguments: arguments })
    raise
  end
end

Rails.application.config.after_initialize do
  if defined?(SolidQueue::Job)
    SolidQueue::Job.prepend(SolidQueueJobExtensions)
  end
end

The after_initialize hook ensures Solid Queue has loaded before attempting the prepend. The defined? check prevents errors if the gem isn't present in certain environments.

Prepend vs Include vs Extend

Understanding when to use each matters:

  • include - Adds methods as instance methods, inserted after the class in lookup chain
  • extend - Adds methods as class methods
  • prepend - Adds methods as instance methods, inserted before the class in lookup chain

Use prepend when the goal is overriding existing behavior while maintaining access to the original via super. Use include for adding new behavior that doesn't need to wrap existing methods.

Common Mistakes to Avoid

Several pitfalls trip up developers new to prepending:

  • Prepending multiple times - Each prepend adds another layer. Guard against double-prepending with prepend Module unless ancestors.include?(Module)
  • Forgetting super - Omitting super completely replaces the original method instead of wrapping it
  • Wrong load order - Prepending before the target class loads causes errors. Use ActiveSupport.on_load or after_initialize hooks
  • Argument mismatches - Always use *args, **options or match the exact signature to avoid breaking when the original method signature changes

Debugging Prepend Chains

When behavior seems wrong, inspect the ancestor chain:

# rails console
Article.ancestors
# => [Article::SaveHooks, Article, Publishable, ApplicationRecord, ...]

# Find where a method is defined
Article.instance_method(:save).owner
# => Article::SaveHooks

The ancestor list shows prepended modules appearing before the class itself, confirming the override order.

Summary

Module prepending provides a robust pattern for extending Ruby and Rails classes. Unlike monkey patching or alias chains, prepend maintains clean inheritance semantics and plays well with other code. Use it for audit logging, timing instrumentation, gem extensions, and any scenario requiring method wrapping. The key rules: always call super to invoke the original, use proper load hooks to avoid timing issues, and guard against double-prepending in initializers that may reload.

10 claps
← Back to Blog