It happens slowly then all at once. Your Rails application starts out clean and simple. The models map neatly to database tables. The controllers are thin. Everything makes sense.
Then you add a feature. And another. You add a callback to the User model to send a welcome email. Then another to subscribe the user to a service. Then a complex method to calculate their lifetime value. Before you know it app/models/user.rb
is a thousand lines long. You have a fat model.
This is a common problem. It’s so common that it’s almost a rite of passage for Rails developers. But just because it’s common doesn’t mean it’s good. A fat model is a sign of trouble ahead. It makes your code hard to understand hard to test and hard to change.
A model’s primary job should be to manage data and its relationships. This includes things like associations validations and scopes. It’s the “M” in MVC. It represents the state of your application.
A model becomes fat when it starts taking on other responsibilities. It starts doing things that belong elsewhere. This is usually business logic.
Consider a User
model. Its core job is to represent a user in the database. But often it accumulates other jobs:
Rails itself can gently nudge you in this direction. Callbacks like after_create
or before_save
make it very easy to hang business logic directly onto a model’s lifecycle. This is fine for simple things but it’s a trap.
The real problem with fat models is that they violate the Single Responsibility Principle. This principle says that a class should have only one reason to change.
When your User
model handles both database persistence and billing logic it has two reasons to change. If you need to alter how users are saved to the database you have to touch that file. If you need to change how you talk to your payment processor you also have to touch that same file.
This creates a few headaches.
First testing becomes difficult. To test a single piece of business logic buried in a model method you might need to create a valid model save it to the database and mock out five other things it interacts with. Your tests become slow and brittle.
Second the code becomes hard to reason about. When you look at user.save
you can’t be sure what will happen. Will it just save to the database? Or will it also charge a credit card send three emails and call an external service? This kind of hidden complexity is where bugs love to hide.
The solution is not to create a complex system of new abstractions. The solution is usually to introduce a very simple pattern. We can call them Service Objects.
A Service Object is just a Plain Old Ruby Object or PORO. It’s a simple class that does one thing. It encapsulates a single piece of business logic.
Let’s imagine our User
model has a method for registering a new user. In a fat model it might look something like this inside the controller:
# in UsersController
def create
@user = User.new(user_params)
if @user.signup_and_charge
# redirect somewhere
else
# render form again
end
end
And the model would contain the messy logic:
# in app/models/user.rb
def signup_and_charge
# lots of logic here
self.activation_token = SecureRandom.hex
if self.save
Stripe::Charge.create(...)
WelcomeEmailWorker.perform_async(self.id)
true
else
false
end
end
This is hard to test. The model is now coupled to Stripe and your background job system.
Instead we can create a service object.
# in app/services/user_registration.rb
class UserRegistration
def initialize(params)
@params = params
@user = User.new(params)
end
def call
return false unless @user.valid?
charge = Stripe::Charge.create(...)
if charge.successful?
@user.save
enqueue_welcome_email
return @user
else
@user.errors.add(:base, “Payment failed.”)
return false
end
end
private
def enqueue_welcome_email
# Handling work outside the request cycle is critical.
# We wrote about this before in [Why Most Rails Apps Get Background Jobs Wrong](https://journalofme.com/blog/rails_background_jobs_wrong).
WelcomeEmailWorker.perform_async(@user.id)
end
end
Now the controller becomes much cleaner:
# in UsersController
def create
registration = UserRegistration.new(user_params)
if @user = registration.call
# redirect somewhere
else
# handle failure
end
end
The User
model is now just a User
model. The business logic for registration lives in one place UserRegistration
. This new class is easy to test. You can initialize it with some parameters and check its behavior without ever touching a real database or payment gateway.
If you’re looking at a large Rails app with fat models the task can seem daunting. Don’t try to fix everything at once.
Start by looking for the most complex method in your fattest model. Create a service object for it. Move the logic from the model into the new service’s call
method. Update the one or two places in your code that called the original method to use your new service object.
Run your tests. If they pass you have made a small part of your codebase significantly better. Repeat this process over time. Slowly you will find your models becoming lean again and your business logic becoming clearer and easier to manage.
This isn’t about dogma. It’s about writing software that you and your team can understand six months from now. Fat models are easy to create but they impose a long term tax on your ability to move fast. Service objects are a simple way to pay down that debt.
Now it’s your turn to think about your own work. What’s the one part of your codebase you'd refactor first?
— Rishi Banerjee
September 2025