Skip to Content
Course content

190: The Method Resolution Order (Ancestors Chain)

Click on the "Edit" button in the top corner of the screen to edit your slide content.

A few years ago, I was mentoring a junior dev who was losing their mind over a bug in a Rails application. They had a User model with a save method, and they were certain the code they wrote was being executed. But in the logs, a weird auditing message was appearing before their logic, and occasionally, their logic wasn't running at all. They had searched the User class, the ApplicationRecord parent class, and the Ruby core docs. Everything looked clean. It turned out a third-party auditing gem had used Module#prepend to hook into the save process. The method was being intercepted before it ever reached the class itself.

This is where most Rubyists hit a wall. We're taught that Ruby looks at the class, then the superclass. But the reality is more like a detailed map—the Method Resolution Order (MRO). When you call a method, Ruby doesn't just jump to the parent; it traverses a very specific "ancestors chain." If you don't know how to read that chain, you're essentially debugging in the dark.

Tracing the Ancestors Chain

The easiest way to stop guessing is to ask Ruby exactly where it's looking. Every class has an .ancestors method that returns an array of all the modules and classes Ruby will check, in the exact order it checks them. I always tell my students: when in doubt, p YourClass.ancestors.

module Trackable
  def log_action
    puts "Action tracked!"
  end
end

class BaseAccount
  def identity
    "Generic Account"
  end
end

class SavingsAccount < BaseAccount
  include Trackable
end

p SavingsAccount.ancestors
# Output: [SavingsAccount, Trackable, BaseAccount, Object, Kernel, BasicObject]

In this flow, Ruby starts at SavingsAccount. If the method isn't there, it moves to Trackable, then BaseAccount, and so on. This is the standard behavior for include. The module gets inserted immediately after the class that includes it, but before the superclass. It's a subtle distinction, but it's the difference between a bug and a feature.

The Power Shift of Prepend

Now, here is where things get interesting—and where my junior dev got tripped up. If include puts a module after the class, prepend puts it before the class. This allows a module to override methods defined directly in the class itself, while still being able to call super to trigger the original class logic.

module ValidationHook
  def save
    puts "Running validations first..."
    super # This calls the next person in the ancestors chain
  end
end

class User
  prepend ValidationHook
  
  def save
    puts "Saving user to database..."
  end
end

p User.ancestors
# Output: [ValidationHook, User, Object, Kernel, BasicObject]

User.new.save
# Output:
# Running validations first...
# Saving user to database...

Notice the change in the ancestors array. ValidationHook is now at the very front. When you call .save, Ruby hits the module first. Because we used super inside the module, Ruby continues down the chain and finds the save method inside the User class. If we had used include, the User class's own save method would have won, and the validation hook would have been ignored unless the User class explicitly called super.

I've found that mastering the ancestors chain is the "unlock" for understanding how most Ruby gems work. They aren't magic; they're just strategically placing themselves in your MRO to intercept calls.




📋 Practical Task

Exercise: Fixing the Payment Processor Ancestry

You are working on a payment system where a PaymentProcessor class handles transactions. A FraudCheck module was intended to intercept every payment to ensure it's safe before the processor handles it. However, the current implementation is ignoring the fraud check entirely.

Your Task: Modify the provided code so that the FraudCheck logic runs before the PaymentProcessor#process method, ensuring the "Checking for fraud..." message appears first in the console. You must do this by changing how the module is integrated into the class, without changing the method bodies themselves.

module FraudCheck
  def process(amount)
    puts "Checking for fraud..."
    super(amount)
  end
end

class PaymentProcessor
  include FraudCheck # This is the line causing the issue

  def process(amount)
    puts "Processing payment of $#{amount}..."
  end
end

processor = PaymentProcessor.new
processor.process(100)
# Current Output:
# Processing payment of $100...
# (The fraud check is missing!)
Rating
0 0

There are no comments for now.

to be the first to leave a comment.