Fat Models, Skinny Controllers — and Why I Moved Beyond Both
by Eric Hanson, Backend Developer at Clean Systems Consulting
Where the mantra came from and what it got right
Fat controllers were the original Rails antipattern. Business logic, query logic, and presentation logic jammed into controller actions because that was the most direct path from route to response. The fix — move logic to models — was correct for one specific class of problem: logic that's about the model's own data and state.
User#full_name, Order#overdue?, Invoice#total — these belong on the model. They're computations over the model's own attributes, they're reusable across controllers and background jobs, and they make the model's interface richer without adding inappropriate dependencies.
The mantra broke down when teams applied it universally. "Move it to the model" became the answer to every logic question, regardless of whether the logic was actually about the model. The result: models that send emails, call payment APIs, enqueue jobs, and update four other models on save. Controllers got thin. Models got obese.
What controllers should actually do
A controller action has exactly one job: translate an HTTP request into a domain operation and translate the result back into an HTTP response. Nothing more. The thinnest legitimate controller action looks like this:
class OrdersController < ApplicationController
def create
result = PlaceOrder.call(
user: current_user,
cart: current_cart,
payment_method: order_params[:payment_method]
)
if result.ok?
render json: OrderSerializer.new(result.value), status: :created
else
render json: { error: result.error }, status: :unprocessable_entity
end
end
private
def order_params
params.require(:order).permit(:payment_method)
end
end
Three responsibilities: parameter extraction, domain operation invocation, response rendering. No business logic. No query construction. No conditional branches beyond success/failure.
This is achievable when the domain operation lives in a service object and the response shape lives in a serializer. Controllers that look like this are trivially testable with request specs — you're testing the HTTP contract, not the business logic.
What models should actually do
Models own their own data and the rules that govern it. The line I use: a model method is appropriate if it only reads or writes the model's own columns and associations, and doesn't have side effects outside the database transaction.
class Order < ApplicationRecord
belongs_to :user
has_many :line_items
# Belongs here — computed from own data
def total
line_items.sum { |item| item.unit_price * item.quantity }
end
# Belongs here — state query on own attributes
def overdue?
due_at.present? && due_at < Time.current && !paid?
end
# Belongs here — own state transition with own validation
def mark_paid!(payment_reference:)
raise AlreadyPaidError if paid?
update!(paid: true, paid_at: Time.current, payment_reference: payment_reference)
end
# Does not belong here — sends email, external side effect
def complete!
update!(status: :completed)
OrderMailer.receipt(self).deliver_later # wrong
Analytics.track("order.completed", id: id) # wrong
end
end
mark_paid! belongs on the model. It's a state transition on the order's own data with a precondition check. complete! as written doesn't — it has side effects outside the model's own state. Those side effects belong in a service object that orchestrates the full completion flow.
The test: can I call this method from a database seed script or a data migration without worrying about network calls, emails, or job queues firing? If yes, it belongs on the model.
The layer that was missing
The architecture that actually scales past a few hundred models is neither fat-model nor skinny-controller. It has four layers with clear responsibilities:
Models — data access, validation, state queries, state transitions on own data.
Query objects — complex ActiveRecord queries that don't belong as scopes, composable, independently testable.
Service objects — multi-step operations that orchestrate models, external services, and side effects. The domain operation layer.
Serializers/Presenters — response shaping, presentation logic, format-specific output.
Controllers become pure HTTP adapters. They speak HTTP on one side and service objects on the other. They know nothing about the domain beyond parameter names and status codes.
The practical impact on a mature codebase: adding a background job version of an operation is trivial — call the same service object from a job instead of a controller. Adding a CLI interface is trivial — call the same service object from a Rake task. The domain logic is in one place with one interface.
The callback problem this solves
Callbacks are the mechanism that made fat models possible. When you need to send a welcome email after a user registers, the tempting path is after_create :send_welcome_email on User. This is the model reaching outside its own boundary — it now has a dependency on UserMailer and implicitly on ActionMailer's delivery configuration.
The consequence: User.create! in a test triggers the callback. Tests need to stub the mailer. Tests that aren't about email still pay the overhead of setting up the mailer stub. Data migrations that create users send emails to real addresses.
Moving the email to a service object eliminates all of this:
class RegisterUser
def self.call(...)= new(...).call
def initialize(params:)
@params = params
end
def call
user = User.create!(@params)
UserMailer.welcome(user).deliver_later
Result.ok(user)
rescue ActiveRecord::RecordInvalid => e
Result.fail(e.message)
end
end
User.create! in isolation does nothing beyond database writes. The email fires only when RegisterUser.call is explicitly invoked. Data migrations use User.create! directly. Tests for RegisterUser stub the mailer. Tests for User don't know the mailer exists.
Where service objects don't replace models
The "move everything to service objects" overcorrection is as damaging as the fat model. Model validations, associations, scopes, and state-query methods belong on the model. A service object for CreateUser that just calls User.create!(params) adds indirection without value.
The test for whether a service object is warranted: does the operation involve more than one model, call an external service, produce side effects outside the database, or have multiple distinct failure paths? If none of those, the model method is correct.
User#deactivate! that sets active: false is a model method. DeactivateUser that sets active: false, cancels the subscription, releases their team seat, and sends a cancellation confirmation is a service object. The domain concept is the same. The complexity is not.
Reading a codebase with this structure
The practical test for whether an architecture is working is how long it takes a new developer to find the code for a specific feature. In a well-structured layered Rails app:
- "Where does user registration happen?" →
app/services/register_user.rb - "Where is the query for overdue orders?" →
app/queries/overdue_orders_query.rb - "Where does the order JSON shape come from?" →
app/serializers/order_serializer.rb - "Where is the validation for email format?" →
app/models/user.rb
Each question has a single, predictable answer. That's the goal. Not thinner models or thinner controllers as ends in themselves — predictable, navigable code where the layer boundaries communicate intent.
The mantra was a useful correction at a specific moment in Rails' history. Taking it to completion means accepting that models, controllers, and a few nameless helper methods aren't enough layers to hold a real application's complexity.