You know that moment when you open a Rails model file and have to scroll for what feels like forever to find the method you need? This was mine – staring at a 1,200-line User model that had become our application's nightmare.
In this issue, I'll take you through my journey from fat models to clean, maintainable architecture. No theory dumps – just real code, real problems, and real solutions.
🚀 What you'll learn:
Why "thin controller, fat model" advice destroys codebases
How to extract service objects that actually make sense
The value object pattern for cleaner data handling
Practical steps to refactor without breaking everything
The Problem
Three months into our Rails project, our main User model had become a monster. What started as innocent database relationships had grown into a beast handling everything from authentication to payment processing to email campaigns.
Here's what we were dealing with:
class User < ActiveRecord::Base
# 15 different associations
has_many :orders, :reviews, :subscriptions, :notifications
belongs_to :company, :billing_address, :shipping_address
# Endless validations
validates :email, presence: true, uniqueness: true
validates :password, length: { minimum: 8 }
validates :phone, format: { with: /\A[\+]?[1-9][\d .\-\(\)]{6,}\z/ }
# Business logic explosion
def upgrade_to_premium
transaction do
update!(subscription_type: 'premium')
charge_credit_card
send_welcome_email
create_analytics_event
sync_with_crm
generate_invoice
notify_sales_team
end
end
def calculate_discount_rate
# 50 lines of complex business logic here
end
# ... 200+ more methods
end
💡 Warning Signs:
Tests took 45 seconds to run for a single model
Adding new features meant touching 5+ unrelated methods
Database fixtures required 12 related objects just to test business logic
New developers needed 3 days just to understand one model
The Journey: From Problem to Solution
Step 1: Extract Service Objects for Complex Operations
Before:
class User < ActiveRecord::Base
def upgrade_to_premium
transaction do
update!(subscription_type: 'premium')
charge_credit_card
send_welcome_email
create_analytics_event
sync_with_crm
generate_invoice
notify_sales_team
end
end
end
After:
# Clean model with only data concerns
class User < ActiveRecord::Base
has_many :orders
belongs_to :company
validates :email, presence: true, uniqueness: true
validates :password, length: { minimum: 8 }
end
# Dedicated service for upgrade logic
class UserUpgradeService
def initialize(user)
@user = user
end
def execute
return false unless eligible_for_upgrade?
ActiveRecord::Base.transaction do
upgrade_subscription
process_payment
send_notifications
track_analytics
end
true
end
private
def eligible_for_upgrade?
@user.subscription_type != 'premium' && @user.payment_method.present?
end
def upgrade_subscription
@user.update!(subscription_type: 'premium', upgraded_at: Time.current)
end
def process_payment
PaymentProcessor.charge(@user.payment_method, premium_price)
end
def send_notifications
UserMailer.premium_welcome(@user).deliver_later
NotificationService.new(@user).send_upgrade_confirmation
end
def track_analytics
Analytics.track(@user.id, 'subscription_upgraded', { plan: 'premium' })
end
end
🎯 Impact:
Test suite ran 60% faster (18 seconds vs 45 seconds)
Service objects could be tested without database setup
Business logic became reusable across different contexts
Step 2: Introduce Value Objects for Complex Data
Before:
class User < ActiveRecord::Base
def formatted_phone
# 20 lines of phone formatting logic
end
def valid_phone?
# Complex validation logic
end
end
After:
class PhoneNumber
attr_reader :value
def initialize(raw_phone)
@value = normalize(raw_phone)
validate!
end
def formatted
case country_code
when '1' then us_format
when '44' then uk_format
else international_format
end
end
def valid?
@value.match?(/^\+\d{10,15}$/)
end
private
def normalize(raw_phone)
raw_phone.to_s.gsub(/[^\d+]/, '')
end
def validate!
raise ArgumentError, "Invalid phone number" unless valid?
end
end
# In the User model
class User < ActiveRecord::Base
def phone_number
@phone_number ||= PhoneNumber.new(phone) if phone.present?
end
end
The Aha Moment
The breakthrough came when I realized we weren't building a database wrapper – we were building a business application that happened to use a database. The database structure shouldn't dictate our code organization.
Real Numbers From This Experience
Before: 1,200-line User model
After: 180-line User model + 8 focused service objects + 4 value objects
Test suite: 45s → 18s runtime
Bug fix time: 2-3 hours → 30 minutes average
New feature development: 40% faster
The Final Result
# Lean User model focused on data persistence
class User < ActiveRecord::Base
has_many :orders
has_many :subscriptions
belongs_to :company
validates :email, presence: true, uniqueness: true
validates :password, length: { minimum: 8 }
def phone_number
@phone_number ||= PhoneNumber.new(phone) if phone.present?
end
def full_name
"#{first_name} #{last_name}".strip
end
end
# Controller stays focused on HTTP concerns
class UsersController < ApplicationController
def upgrade
@user = current_user
service = UserUpgradeService.new(@user)
if service.execute
redirect_to dashboard_path, notice: "Welcome to Premium!"
else
flash.now[:alert] = "Upgrade failed. Please try again."
render :show
end
end
end
🎉 Key Improvements:
Models handle only data persistence and simple queries
Service objects contain isolated business logic
Value objects encapsulate complex data types
Controllers focus on HTTP request/response flow
Monday Morning Action Items
Quick Wins (5-Minute Changes)
Identify your fattest model (hint: it's probably User)
Count the public methods – if it's over 20, you have work to do
Look for methods with more than 5 lines that aren't simple queries
Next Steps
Extract one complex method into a service object this week
Create a value object for any data with formatting or validation logic
Write tests for your new objects without touching the database
Your Turn!
The Fat Model Challenge
Here's a typical fat model. How would you refactor it?
class Order < ActiveRecord::Base
belongs_to :user
has_many :line_items
def process_payment
return false unless valid_for_payment?
transaction do
charge_result = CreditCardProcessor.charge(
user.credit_card,
total_amount
)
if charge_result.success?
update!(status: 'paid', paid_at: Time.current)
generate_receipt
send_confirmation_email
update_inventory
create_shipment
track_conversion
true
else
errors.add(:payment, charge_result.error)
false
end
end
end
def generate_receipt
# 30 lines of PDF generation
end
def send_confirmation_email
# Email logic
end
def update_inventory
# Inventory management
end
end
💬 Discussion Prompts:
What service objects would you extract from this Order model?
Which parts belong in the model vs external services?
How would you test the payment processing logic?
🔧 Useful Resources:
Found this useful? Share it with a fellow Rails developer! And don't forget to reply with your own fat model horror stories – I read every response.
Happy coding!
Tips and Notes:
Note: All code examples are simplified for clarity – real implementations should include proper error handling
Pro Tip: Start with service objects before jumping to repositories – you can always add more abstraction later
Remember: The goal isn't perfect architecture, it's maintainable code that your team can work with confidently