Taming Rails God Objects with Plain Old Ruby Objects: A Practical Guide
What to do when your rails models are out of control
If you’ve worked with Rails long enough, you’ve likely encountered the dreaded “God Object” — that massive User model that seems to know everything about your system. That model that has more methods than your README has lines, and adding a new feature somehow always means touching it. Your churn scores are horrifying and every time you tackle it you hold your breath. That one.
You might be dealing with a God Object if you see:
- Files that require scrolling… and scrolling… and scrolling
- Method names that keep getting longer (
calculate_subscription_discount_for_plan_type
) - Callbacks that trigger mysterious side effects
- Tests that require extensive setup and knowledge of the entire system (with branching logic that makes your head spin)
- New features that somehow always need to touch this model
Why The Traditional Rails Solutions Often Fall Short
The typical Rails solutions often include:
Moving code to concerns:
This is the equivalent, IMO, of stuffing your dirty laundry under the bed. Sure, your User model looks cleaner, but it isn’t immediately apparent when you look at the model that something is happening. I’ve worked on apps that have had 15–20 concerns for each model. That’s I think the very definition of monsters under the bed.
Creating service objects:
Ruby is an object oriented language so to me, service objects always felt dirty. We’ve got all of these objects, and all of a sudden we break and create something that is uncomfortably close to functional programming. It also means that changes in business logic can impact a lot of files, and every time you touch a file, you run the risk of inserting <bugs>. Comprehensive tests help, but as we all know, it’s hard to find every edge case, and often codebases are leaner on tests than we would like.
Adding more scopes and class methods:
This isn’t so much a solution, as it is an attempt at abstraction. This further bloats the model and tries to disguise complexity. In codebases I’ve come across like this, following the bouncing ball is darned near impossible.
So what can we do?
A Better Way: Domain-Focused POROs
Instead of scattering our domain logic across concerns or service objects, we can use Plain Old Ruby Objects (POROs) within our models directory to create clear, focused abstractions. This approach, inspired by Sandi Metz’s object-oriented design principles, keeps related behavior together while maintaining single responsibility.
Example 1: Extracting Name Logic
Before your user model might look like this:
class User < ApplicationRecord
has_many :subscriptions
has_many :orders
has_many :projects
has_many :notifications
has_many :payment_methods
belongs_to :organization
has_one :profile
has_one :preferences
validates :email, presence: true, uniqueness: true
validates :first_name, :last_name, presence: true
before_save :normalize_phone
before_create :set_default_preferences
after_create :send_welcome_email
after_save :update_search_index
scope :active, -> { where(status: 'active') }
scope :subscribers, -> { joins(:subscriptions).where(subscriptions: { status: 'active' }) }
scope :trial, -> { where('trial_ends_at > ?', Time.current) }
def full_name
[first_name, last_name].compact.join(' ')
end
def display_name
full_name.presence || username
end
def formal_name
[title, last_name].compact.join(' ')
end
def professional_name
[title, credentials, full_name].compact.join(' ')
end
def short_name
[first_name, last_name.try(:first)].compact.join(' ')
end
def name_with_email
"#{full_name} <#{email}>"
end
def subscription_status
return 'trial' if trial?
return 'expired' if subscription_expired?
return 'active' if subscribed?
'none'
end
def trial?
trial_ends_at&.future?
end
def subscription_expired?
subscriptions.active.none? && subscriptions.any?
end
def subscribed?
subscriptions.active.any?
end
def active_subscription
subscriptions.active.order(created_at: :desc).first
end
def can_access_feature?(feature)
return true if admin?
return false unless subscribed?
active_subscription.plan.features.include?(feature)
end
def billable?
payment_methods.active.any?
end
def billing_address
{
street: billing_street,
city: billing_city,
state: billing_state,
zip: billing_zip,
country: billing_country
}
end
def total_spent
orders.completed.sum(:total_amount)
end
def average_order_value
return 0 if orders.completed.none?
total_spent / orders.completed.count
end
def last_login_status
if last_login_at.nil?
'never'
elsif last_login_at > 1.day.ago
'recent'
elsif last_login_at > 30.days.ago
'inactive'
else
'dormant'
end
end
def notify!(message, level = :info)
notifications.create!(
message: message,
level: level,
read_at: nil
)
end
def merge_with(other_user)
ActiveRecord::Base.transaction do
other_user.orders.update_all(user_id: id)
other_user.subscriptions.update_all(user_id: id)
other_user.payment_methods.update_all(user_id: id)
other_user.destroy
end
end
def self.search(query)
where("CONCAT_WS(' ', first_name, last_name, email) ILIKE ?", "%#{query}%")
end
private
def normalize_phone
self.phone = phone.to_s.gsub(/[^\d]/, '')
end
def set_default_preferences
build_preferences(
email_notifications: true,
newsletter: false,
theme: 'light'
)
end
def send_welcome_email
UserMailer.welcome(self).deliver_later
end
def update_search_index
SearchIndexWorker.perform_async('User', id)
end
end
Let’s extract all name-related behavior into its own object:
# app/models/user/name.rb
class User::Name
attr_reader :first, :last, :title, :username
def initialize(first:, last:, title: nil, username: nil)
@first = first
@last = last
@title = title
@username = username
end
def full
[first, last].compact.join(' ')
end
def display
full.presence || username
end
def formal
[title, last].compact.join(' ')
end
end
Now our User model becomes cleaner:
class User < ApplicationRecord
def name
@name ||= User::Name.new(
first: first_name,
last: last_name,
title: title,
username: username
)
end
end
This pattern means all your name formatting logic lives in one place, but you can still access it naturally through the user object. The @name ||=
memoization means the Name object is only created once per instance instead of every time you call user.name.
How to use it? When you need a user name, just call the methods on name instead of directly on the user.
user.name.full
user.name.formal
user.name.professional
Example 2: Managing Subscription Status
Complex status checking can be encapsulated in its own object:
# app/models/user/subscription_status.rb
class User::SubscriptionStatus
attr_reader :user
def initialize(user)
@user = user
end
def active?
user.subscribed? && !expired? && !trial?
end
def trial?
user.trial_ends_at&.future?
end
private
def expired?
user.subscription_ends_at&.past?
end
end
Example 3: Complex Calculations
Usage calculations get their own focused object:
# app/models/user/usage_calculator.rb
class User::UsageCalculator
attr_reader :user
def initialize(user)
@user = user
end
def current_month
@current_month ||= calculate_usage(Time.current.beginning_of_month)
end
private
def calculate_usage(start_date)
user.events
.where(created_at: start_date..Time.current)
.group_by(&:category)
.transform_values(&:count)
end
end
The Benefits
This approach offers several advantages:
Clear Domain Boundaries:
Each object has a single, clear responsibility. This is one of the tenets of SOLID design.
Easier Testing:
Objects can be tested in isolation and the tests can be reliable. A name doesn’t really care what it’s given, it could be a User Model, a Client Model or an Admin Model (assuming you haven’t used class inheritence to define those, which is a topic for another day).
You can test that if you give the User::Name.new class the first name of Bob and the last name of Henry, it will return Bob Henry regardless of any other testing setup. In addition, since it doesn’t require the use of an entire User model, if you change the User model to now require an Organization, you don’t have to change any of the User::Name tests — it’s not dependent on the User being a specific way.
This has the side benefit of making your tests faster as they aren’t relying on database writes to test whether entering Bob and Henry returns “Bob Henry”. There are times when you want the tests to impact the database, but for a full name method that seems like overkill (and slows down your tests, another bane of the developer existence).
Maintainable File Sizes:
While scrolling a very long file is certainly painful, made more simple with find in an IDE, there’s a very real danger to a very long scrolling object. It’s very easy to accidentally introduce bugs. Recently, when tackling a model that I hadn’t taken the time to refactor (we all do it, oops!) I accidentally moved a method that was necessary to a view below a private declaration, and this application was for a client whose tests didn’t run (and they didn’t want to pay me to detangle them), I didn’t catch it, thereby introducing a bug. Easy enough to find in the logs, not what you want for production.
Clear Dependencies:
Objects explicitly declare what they need. I don’t know if I need to say how useful this is, but this clarity becomes super important when you find yourself looking at the object in 6 months to a year. And adding a little bit of documentation in the form of comments above each one gives you a chance to communicate to yourself or to other developers what you might have been thinking at the time. This follows the principle of “Code like the guy/gal coming behind you has a g*n”
No Monkey-Patching:
Unlike concerns, we’re not extending core classes. We’re adding models specific to a domain, and we’re namespacing them to stay organized.
Rails-Native Feel:
Still feels natural in a Ruby on Rails application. Maybe it’s just me, but service objects always felt like an antipattern.
Real-World Impact
In a recent project, we inherited a User model with over 30 different status-related methods. By creating focused User::Status
and User::PermissionSet
POROs, we not only made the code more maintainable but also made it immediately clear where new status or permission-related code should go.
Key Principles to Remember
When creating model-layer POROs:
- Each object should have a single, clear responsibility
- Objects should tell a story about your domain
- Keep objects focused on business logic, not framework concerns
- Use namespacing to show relationships
- Keep public interfaces small and focused
Getting Started
The best way to start is by identifying clusters of related methods in your models. Look for:
- Methods that share similar names or prefixes
- Methods that operate on the same attributes
- Calculations that use the same subset of data
- Status checks that relate to the same concept
Start small, extract one cluster at a time, and your code will become more maintainable with each refactoring.
Next up, I’ll talk about refactoring horribly complex Controller methods!
Have you tried using POROs to organize your Rails models? What patterns have you found most helpful? Share your experiences in the comments below.