Why Your ActiveRecord Models Are Too Smart (And How I Fixed Mine)
Three days. That’s how long I spent hunting down a bug in what should have been a simple credit calculation.
The logic was buried inside an 800-line ActiveRecord model with conditionals, callbacks, and database operations all tangled together.
You know that moment when you realize the bug isn’t the problem—the entire architecture is? This was mine.
In this issue, I’ll take you through the refactoring journey that cut our test suite time by 40% and made our codebase actually understandable. No theory dumps—just real code, real problems, and real solutions.
What you’ll learn:
How to identify when ActiveRecord models are doing too much
The value object pattern for extracting business logic
How to test business rules without database overhead
Practical refactoring steps that improve maintainability
The Problem
Here’s what we were dealing with—a typical Rails model that started simple and grew into a monster:
class Invoice < ApplicationRecord
# 800 lines of associations, validations, business logic,
# calculations, callbacks, and helper methods all mixed together
def add_one_month_of_credit!
current_date = self.paid_through_date || Date.today
duration = 1.month - 1.day
self.paid_through_date = current_date + duration
self.save!
end
# ... 50 more methods like this
end
Warning Signs:
Single model file exceeding 500 lines
Methods mixing business logic with database operations
Integration tests required to verify simple calculations
Fear of changing anything because of hidden dependencies
New developers taking days to understand one model
The Journey: From Problem to Solution
Step 1: Recognizing the God Object Pattern
The first breakthrough: understanding that ActiveRecord models should be boring. They should handle database operations, not business logic.
Before:
def add_one_month_of_credit!
current_date = self.paid_through_date || Date.today
duration = 1.month - 1.day
self.paid_through_date = current_date + duration
self.save!
end
After:
# Business logic extracted to value object
module Credits
OneMonth = Struct.new(:current_date, :duration, keyword_init: true) do
def self.for(invoice)
new(
current_date: invoice.paid_through_date || Date.today,
duration: 1.month - 1.day
)
end
def to_h
{ paid_through_date: paid_through_date }
end
private
def paid_through_date
current_date + duration
end
end
end
# Model stays thin
class Invoice < ApplicationRecord
def add_credit!(credit)
update!(credit.to_h)
end
end
# Usage
invoice.add_credit!(Credits::OneMonth.for(invoice))
Impact:
Business logic now has a clear name:
Credits::OneMonthCredit calculation testable without database setup
Model reduced from 800 to 200 lines
Step 2: Testing Without Database Overhead
The second win: testing business logic became trivial.
Before (Integration Test):
RSpec.describe Invoice do
it “adds one month of credit” do
invoice = create(:invoice, paid_through_date: Date.new(2024, 1, 1))
invoice.add_one_month_of_credit!
expect(invoice.paid_through_date).to eq(Date.new(2024, 1, 31))
end
end
After (Unit Test):
RSpec.describe Credits::OneMonth do
it “calculates one month of credit” do
invoice = double(paid_through_date: Date.new(2024, 1, 1))
credit = described_class.for(invoice)
expect(credit.to_h[:paid_through_date]).to eq(Date.new(2024, 1, 31))
end
end
Impact:
Test suite runs 40% faster (fewer database operations)
Tests run in milliseconds instead of seconds
Business logic failures immediately obvious
Step 3: Making Domain Concepts Explicit
The final realization: code should speak the language of the business.
# Multiple credit types now easy to add
module Credits
OneMonth = Struct.new(:current_date, :duration, keyword_init: true) do
# ... implementation
end
ThreeMonths = Struct.new(:current_date, :duration, keyword_init: true) do
def self.for(invoice)
new(
current_date: invoice.paid_through_date || Date.today,
duration: 3.months - 1.day
)
end
# ... rest of implementation
end
Annual = Struct.new(:current_date, :duration, keyword_init: true) do
# ... implementation
end
end
# Clear, readable usage
invoice.add_credit!(Credits::ThreeMonths.for(invoice))
invoice.add_credit!(Credits::Annual.for(invoice))
The Aha Moment
The breakthrough came when I stopped thinking about “how do I organize this code” and started asking “what business concepts am I modeling?”
The code wasn’t just calculating dates—it was representing different types of subscription credits. Making that concept explicit through value objects transformed the entire codebase.
Real Numbers From This Experience
Before: 800-line Invoice model, 12-second test suite for that model
After: 200-line Invoice model, 3-second test suite
Bug discovery time: Reduced by 60%
Feature velocity: New credit types added in hours instead of days
Onboarding time: New developers productive in days instead of weeks
The Final Result
# app/models/invoice.rb (thin and focused)
class Invoice < ApplicationRecord
has_many :invoice_items
belongs_to :customer
validates :paid_through_date, presence: true
def add_credit!(credit)
update!(credit.to_h)
end
def build_invoice_items(items:)
items.map { |item| invoice_items.create!(item) }
end
end
# app/models/credits/one_month.rb (business logic)
module Credits
OneMonth = Struct.new(:current_date, :duration, keyword_init: true) do
def self.for(invoice)
new(
current_date: invoice.paid_through_date || Date.today,
duration: 1.month - 1.day
)
end
def to_h
{ paid_through_date: paid_through_date }
end
private
def paid_through_date
current_date + duration
end
end
end
Key Improvements:
ActiveRecord model focuses only on database concerns
Business logic isolated in testable value objects
Clear separation between domain concepts and persistence
Each class has a single, clear responsibility
Monday Morning Action Items
Quick Wins (5-Minute Changes)
Identify your largest ActiveRecord model
Find one method that mixes business logic with persistence
Count how many lines would need database setup to test
Next Steps
Extract one calculation into a value object
Write a unit test for the extracted logic
Measure test speed improvement
Repeat for one more method
Your Turn!
The Model Refactoring Challenge
Look at this typical ActiveRecord method:
class Subscription < ApplicationRecord
def calculate_renewal_amount
base_price = self.plan.base_price
discount = self.active_promotion ? self.active_promotion.discount_percentage : 0
tax_rate = self.customer.region.tax_rate
subtotal = base_price * (1 - discount / 100.0)
total = subtotal * (1 + tax_rate / 100.0)
self.update!(renewal_amount: total)
total
end
end
Discussion Prompts:
What business concepts are hidden in this method?
How would you extract the pricing logic into value objects?
What would the tests look like before and after extraction?
Useful Resources:
Refactoring: Ruby Edition - Classic refactoring patterns
Domain-Driven Design Distilled - Value objects and bounded contexts
Rails AntiPatterns - Common Rails mistakes and solutions
Found this useful? Share it with a developer who’s wrestling with a giant ActiveRecord model. Reply with your own refactoring wins—I read every response.
Happy coding!
Tips and Notes:
Note: Start with one small extraction. Don’t try to refactor everything at once.
Pro Tip: Value objects work great with Struct for simple cases, but can be full classes for complex domain logic.
Remember: If you’re using
letorbeforeblocks to set up database state in tests, that’s a sign business logic might belong elsewhere.
