Spaghetti Model Part 4: Safely Remove ActiveRecord Associations and Scopes

Todd Sedano
Gusto Engineering
Published in
4 min readFeb 20, 2024

Note: This is one part of my journey to tame a spaghetti model or god object. Start with Post 1: Unraveling a Spaghetti Model to see how I chose to tackle this problem.

Your spaghetti model has been a dumping ground of methods and associations for years.

Overview

In your journey to detangle a spaghetti model, you may come across ActiveRecord associations that you are pretty sure aren’t getting used, but you want to add a deprecation warning just in case you are wrong.

Rails makes it easy to have a model access all of its related entities through belongs_to, has_many and has_one helpers. These convenience methods allow us to quickly prototype and build new features. Rails and AREL make it easy for your models to navigate across database relationships. Gone are the days of writing lots of SQL to get a website up and running. However, with this power comes a cost. It’s too easy for an object to reach across domain boundaries and use methods of a distant object.

An Example

Consider this example:

company.funding_methods.payroll.reverse_wire.verified.default.first.present?

Should a company really be able to access its reverse_wires through funding_methods and payrolls? Nope! In our system, this chaining is breaking the boundaries of different domains.

The associations allow us to easily reach through many objects to talk to use a method on our neighbor’s neighbor’s neighbor’s method. This anti-pattern is called a Law of Demeter violation.

Strategy

Step 1: Remove all callers of the association or scopes.

Step 2: Add a method with the same name as the association or scope with a deprecation warning.

Step 3: Fix any failing tests.

Step 4: Deploy, wait, and verify that it’s not used in production.

Step 5: Remove the deprecated association or scope and the method you added in Step 2.

Example 1: Simple example

Here we have a fraud scope that we would like to remove:

# existing code
class Company < ApplicationRecord
scope :fraud, -> { where(risk_state: FRAUD_STATES) }
end

We simply add an override method that calls a deprecation warning.

# updated code
class Company < ApplicationRecord
scope :fraud, -> { where(risk_state: FRAUD_STATES) }

def self.fraud
ActiveSupport::Deprecation.warn("Company.fraud at #{caller}")
super
end
end

This technique works for has_many , has_one , belongs_to , and scopes.

class Company
has_many :payroll

def payrolls
ActiveSupport::Deprecation.warn("Use Payroll.where(company_id: id)
instead of company.payrolls at #{caller}")
super
end

def payrolls=(value)
ActiveSupport::Deprecation.warn("Use Payroll.where(company_id: id)
instead of company.payrolls= at #{caller}")
super(value)
end
end

# usage
payrolls = Payrolls.where(company_id: company_id)

Dependent Destroy

If your association has a dependent destroy, remove the destroy first, then do the simple association listed above.

# existing code
class Company
has_many :risk_reviews, dependent: :destroy,
end

We extract out a destroy a destroy_associated_risk_reviews method.

# updated code
class Company
has_many :risk_reviews

before_destroy :destroy_associated_risk_reviews

def destroy_associated_risk_reviews
RiskReviews.where(company_id: id).destroy_all
end
end

After we have removed our associations, we would move the destroy_associated_risk_reviews method to the domain pack that contains RiskReviews. In our case, this would be our risk pack. When a company is deleted, we use an event system to notify the risk pack to delete its risk reviews. The event system would enqueue a sidekiq job that deletes risk reviews for a particular company.

module RiskReviews
class CompanyDeleted

sig { params(company_id: Integer).void }
def perform(company_id)
RiskReviews.where(company_id: company_id).destroy_all
end
end
end

Example: Polymorphic Relationships

This is a little more nuanced for a polymorphic relationship. The payer_origination_bank has two columns to represent the polymorphism: payer_id (which is a foreign key to the company, etc) and payer_type (which represents the name of the model, Company, etc).

# existing code
class Company
has_many :payer_origination_banks, as: :payer
end


# usage
company = Company.find(company_id)
banks = company.payer_origination_banks

We could replace company.payer_origination_banks with PayerOriginationBank.where(payer_id: companyid, payer_type: Company), however, that’s hard to read and odds are we’ll forget to pass in payer_type at some point. We’d like the usage to be something simple like PayerOriginationBank.by_company_id(company_id: id), so let’s add a scope.

# updated code
class Company
has_many :payer_origination_banks, as: :payer

def payer_origination_banks
ActiveSupport::Deprecation.warn("Use PayerOriginationBank.by_company_id(company_id: id) instead of company.payer_origination_banks at #{caller}")
super
end

def payer_origination_banks=(value)
ActiveSupport::Deprecation.warn("... instead of company.payer_origination_banks= at #{caller}")
super(value)
end
end

class PayerOriginationBank
scope :by_company_id, -> (company_id) do
where(payer_id: company_id, payer_type: Company.name)
end
end

# preferred usage
PayerOriginationBank.by_company_id(company_id: company_id)

Example 2: Complex Association

This technique works with complicated associations. Consider this example:

# existing code
class PaymentRecord < ApplicationRecord
belongs_to(
:company,
-> do
includes(:payment_records).where(payment_records: { on_behalf_of_type: Company.name })
end,
class_name: :Company,
foreign_key: :on_behalf_of_id,
optional: true
)
end

We can simply add a deprecation method:

# updated code
class PaymentRecord < ApplicationRecord
...

def self.company
ActiveSupport::Deprecation.warn("PaymentRecord.company at #{caller}")
super
end
end

Summary

By adding override methods with deprecation warnings, we can verify that a scope or association is no longer used and is safe to delete.

Originally published at https://sedano.org on August 29, 2023.

--

--