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.

Scale Your Backend - Need an Experienced Backend Developer?

We provide backend engineers who join your team as contractors to help build, improve, and scale your backend systems.

We focus on clean backend design, clear documentation, and systems that remain reliable as products grow. Our goal is to strengthen your team and deliver backend systems that are easy to operate and maintain.

We work from our own development environments and support teams across US, EU, and APAC timezones. Our workflow emphasizes documentation and asynchronous collaboration to keep development efficient and focused.

  • Production Backend Experience. Experience building and maintaining backend systems, APIs, and databases used in production.
  • Scalable Architecture. Design backend systems that stay reliable as your product and traffic grow.
  • Contractor Friendly. Flexible engagement for short projects, long-term support, or extra help during releases.
  • Focus on Backend Reliability. Improve API performance, database stability, and overall backend reliability.
  • Documentation-Driven Development. Development guided by clear documentation so teams stay aligned and work efficiently.
  • Domain-Driven Design. Design backend systems around real business processes and product needs.

Tell us about your project

Our offices

  • Copenhagen
    1 Carlsberg Gate
    1260, København, Denmark
  • Magelang
    12 Jalan Bligo
    56485, Magelang, Indonesia

More articles

How to Avoid Misunderstandings With Remote Clients

Remote work can make communication tricky. Here’s how to keep your projects clear and clients on the same page.

Read more

The Hidden Cost of Large Engineering Teams

Big teams look impressive on paper. But behind the scenes, they often move slower, cost more, and create new kinds of problems.

Read more

When One Developer Chooses a Technology Nobody Else Understands

You trusted your developer to pick the right tools. Now the rest of the team can’t touch the code without a manual in another language.

Read more

What Clients Often Get Wrong When Outsourcing Backend Development

“We just need someone to build the backend.” That sentence sounds simple — until reality shows up.

Read more