Where Does Business Logic Actually Live? A Field Guide

The create action was 180 lines and touched four models, two APIs, and a mailer. Nobody wrote it that way — it grew. On thin controllers, honest service objects, and gods.

ControllerInvoice::CreatePayments::ChargeReceipt::Sendwhere does it live?
Bob
Senior Backend Engineer
Apr 8, 2026
6 min read

I recently reviewed a controller action that I want to describe to you in full, because I promise you've seen its cousin. OrdersController#create was 187 lines. It validated inventory, calculated three kinds of discount, charged a card, decremented stock, created a shipment record, enqueued two jobs, sent an email, updated the customer's loyalty tier, and — my favorite part — contained a comment reading # TODO: move this somewhere.

Nobody wrote that action. That's the thing. It was written by eleven people over four years, each adding one reasonable if block to code that was already there, because the code that was already there established where this kind of logic goes. The first fifteen-line version set the precedent; the next 172 lines just followed it.

So let's talk about where business logic actually belongs — not as doctrine, but as a practical matter of what makes code testable, reusable, and safe to change.

What controllers are actually for

A controller's honest job description is short: translate HTTP into a domain operation, and translate the result back into HTTP. Parse and authorize the input, invoke one thing, and render or redirect based on what came back.

def create
  result = Orders::Place.call(
    customer: current_customer,
    cart: current_cart,
    payment_token: params.require(:payment_token)
  )

  if result.success?
    redirect_to order_path(result.order)
  else
    redirect_to cart_path, alert: result.error
  end
end

Everything HTTP-flavored stays (params, sessions, status codes, redirects). Everything business-flavored leaves. The test for whether logic belongs in a controller: would this rule still exist if we exposed the same operation over a CLI, a webhook, or a background job? Discount calculation? Still exists — it leaves. Redirect target? HTTP-only — it stays.

Why be strict about this? Three concrete reasons, not aesthetic ones:

  • Reuse. The day the mobile API needs to place orders, a fat controller forces you to either duplicate 187 lines or do the extraction under deadline pressure. (Guess which one happens.)
  • Testability. Controller tests drag the full HTTP stack along — routing, middleware, sessions. Testing discount edge cases through post :create is slow and indirect. A plain Ruby object tests in microseconds.
  • Visibility. An operation with a name (Orders::Place) can be found, discussed, and diffed. Line 94 of a create action cannot.

"Move it to the model" and the god object problem

The classic advice was "skinny controller, fat model," and it's half right. Validations, associations, scopes, and small intrinsic behaviors absolutely belong on the model — order.total, order.shippable?, things that are true about an order anywhere it appears.

But push every workflow into the model and you get the other monster: the god object. A User or Order class with 1,200 lines and forty callbacks isn't better than a fat controller — it's worse, because everything in the system touches it. The after_save callback that sends a loyalty email fires during your data-migration script at 2 a.m., and now you've emailed 40,000 people about points they earned in 2019. (A colleague of mine did approximately this. I've made my own equivalent donation to the incident-report archive, so no stones thrown.)

The distinction I use: models hold facts and invariants about one entity. Workflows that coordinate multiple entities and side effects — charge, decrement, notify, enqueue — belong to something whose whole job is that workflow.

Service objects, without the cargo cult

That something, in most Rails codebases, is a service object: a plain class, named after the operation, with one public method.

module Orders
  class Place
    Result = Struct.new(:success?, :order, :error, keyword_init: true)

    def self.call(customer:, cart:, payment_token:)
      new(customer:, cart:, payment_token:).call
    end

    def call
      return failure("Cart is empty") if cart.empty?

      order = nil
      ActiveRecord::Base.transaction do
        order = create_order!
        reserve_inventory!(order)
      end
      charge_payment!(order)          # external call: outside the transaction
      OrderMailer.confirmation(order).deliver_later
      Result.new(success?: true, order:)
    end
    # ...
  end
end

A few hard-won opinions on doing this well:

Name the operation, not the noun. Orders::Place, Subscriptions::Cancel, Reports::Generate. A class called OrderService with nine public methods is just a junk drawer with a nicer label — the fat controller problem relocated.

Return a result, don't raise for business outcomes. "Card declined" isn't exceptional; it's Tuesday. Results for expected outcomes, exceptions for genuine surprises.

Mind the transaction boundary. Notice the payment call sits outside the DB transaction. External calls inside transactions hold locks for the duration of someone else's latency — a slow payment API plus an open transaction is a database incident kit. This, by the way, is the single most common flaw I see in generated service objects: coding assistants have thoroughly absorbed the service-object pattern and happily wrap the entire method — HTTP calls, mailers, and all — in one tidy transaction do block. It looks disciplined. It's a lock held for five seconds.

Keep them boring. No inheritance trees of BaseService, no metaprogrammed call DSLs. The entire value of the pattern is that it's a plain object a new teammate can read top to bottom.

And when a service grows past a screen or two, that's not failure — that's the design telling you there are smaller operations inside (Inventory::Reserve, Payments::Charge) waiting to be named.

How the fat creeps back

The 187-line controller didn't start at 187 lines, and your clean service won't stay clean by itself. The creep vectors are predictable: the "it's just three lines" addition to the controller (three lines eleven times is the whole story); callbacks accreting on models because a service felt like overkill; and — the modern accelerant — AI assistants extending whatever pattern the surrounding file exhibits. Generated code is aggressively local: ask for a feature in a fat controller and you'll get a fatter controller, idiomatically extended. The assistant follows precedent; only a reviewer can question it. Which means the most valuable review comment is often not about the diff's correctness but its address: "this works — should it live here?"

What I'd do instead

My working rules, none of them absolute:

  1. Controllers: params in, one call, response out. If a controller action has a second responsibility, it's carrying someone else's luggage.
  2. Models: facts and invariants about one entity. Multi-entity workflows and side effects move out. Be deeply suspicious of callbacks that do more than maintain the record itself.
  3. One service per operation, named as a verb phrase, returning a result object.
  4. External calls outside transactions. Always worth thirty seconds of checking, doubly so in generated code.
  5. In review, ask "should this live here?" as routinely as "does this work?" The second question protects this deploy; the first protects the next four years.

Structure isn't tidiness for its own sake. It's the difference between a codebase where the next feature takes a day and one where it takes a week and a prayer — and it's decided fifteen lines at a time, mostly in review.

architecturerailsservice-objectsrefactoringdesign
Written by
Bob
Senior Backend Engineer · Firetrail review team
More Backend