Ruby & Rails

Fat models, skinny controllers, and the third option

Both halves of the old advice produce the same result at scale: one enormous class. Naming the operation is what actually helps.

6 August 2026 2 min read Mohammad Aaquib Jawed

The advice was skinny controllers, fat models. It was correct about controllers and quietly disastrous about models, because "put it in the model" has no stopping rule.

Five years later User is two thousand lines and touches billing, notifications, onboarding and analytics.

The missing concept

A model represents a thing. An operation is not a thing — it is a verb, and verbs need somewhere to live.

"Publish a post" involves a post, an author, notifications and a search index. It does not belong to any one of those models; it belongs to itself. Give it a class and the question of where the code goes answers itself.

The shape barely matters — a plain class with a call method is enough. What matters is that the operation has a name, a single entry point, and explicit dependencies.

What you get

Testing becomes direct: you call the operation and assert on the outcome, rather than constructing a request to reach it.

Reuse stops being copy-paste. The controller, the rake task and the job all call the same object rather than each assembling the steps.

And the transaction boundary becomes visible. When the whole operation lives in one method you can see what must succeed together, which is nearly impossible to reason about when it is scattered across callbacks and controller actions.

"Where does this go?" is usually the wrong question. "What is this operation called?" answers it.

The failure mode

Service objects go wrong when they become a dumping ground — a directory of forty classes named after nothing in particular, each a thin wrapper around one Active Record call.

A service object should represent something a person would recognise as a task. If you cannot name it without using the word "manager", "handler" or "processor", it probably is not one.

Simple CRUD needs no ceremony. Post.create! is fine. The moment there is a second step that must happen with it, you have an operation.

Return something honest

The last detail that matters is what these objects return. A boolean forces callers to guess why something failed. Raising for expected failures turns control flow into exception handling.

Return a result carrying success or failure plus the reason — even a small struct is enough. The controller then branches on something explicit rather than inspecting the model for clues, and the failure paths stop being an afterthought.

All writing Reply by email