When Rails Conventions Are Not Enough

David Morales David Morales
/
A store counter overflowing with boxes and cables being reorganized into four orderly, labeled horizontal shelves.

An online store built with Rails starts out as a pleasure to work on. You have Product, Cart, Order, and a handful of controllers you can read in a minute. The conventions tell you where everything belongs.

Two years later, Order has nine hundred lines. There are three different ways to calculate a discount, and the checkout controller has become that file nobody likes to modify.

This situation is common. Rails comes with models, views, and controllers, and the habit is to divide all the code among them. That organization works while the code fits in one of those places. The problem starts when code fits none of them and ends up in the “least bad” place.

This article addresses that question: where to put coordination when models and controllers start taking on too much. First, the back-and-forth between fat controllers and fat models; then, service objects as an initial remedy and their limits; and finally, layered architecture as a structural way forward.

The Cycle: From Fat Controllers to Fat Models and Back

Business logic in a Rails application often ends up in a controller or a model. Rails lets us create any Ruby class, but those are the two places it provides out of the box. Having only two places does not tell us what belongs in each, so the usual approach tends to swing between them.

Checkout in the Controller

A store’s purchase process (the checkout) usually begins when the customer confirms their cart, enters a coupon, and pays. Here is an example with all the logic in the controller:

class CheckoutsController < ApplicationController
before_action :authenticate_customer!
def create
cart = current_customer.cart
return redirect_to(cart_path, alert: "Your cart is empty") if cart.items.empty?
unavailable = cart.items.select { |item| item.product.stock < item.quantity }
return redirect_to(cart_path, alert: "Some products are out of stock") if unavailable.any?
subtotal = cart.items.sum { |item| item.product.price * item.quantity }
coupon = Coupon.active.find_by(code: params[:coupon_code])
discount = coupon ? subtotal * coupon.percentage / 100 : 0
charge = PaymentGateway.charge(amount: subtotal - discount, token: params[:payment_token])
return redirect_to(cart_path, alert: charge.error_message) unless charge.success?
order = current_customer.orders.create!(
total: subtotal - discount, discount: discount, payment_reference: charge.id
)
cart.items.each do |item|
order.line_items.create!(product: item.product, quantity: item.quantity, unit_price: item.product.price)
item.product.decrement!(:stock, item.quantity)
end
OrderMailer.confirmation(order).deliver_later
cart.items.destroy_all
redirect_to order_path(order), notice: "Thank you for your order!"
end
end

PaymentGateway is a small adapter around whichever payment gateway the store uses, and its charge method charges immediately. What matters here is who decides to charge, rather than how the charge works.

A quick way to evaluate this code is to list the reasons this action might change:

That is seven or eight reasons to change in a single method. A controller should have one: translate an HTTP request into an action, and the action’s response back into HTTP. Everything else is business logic that ended up here because it was the first place at hand.

The Universal Recipe: Thin Controllers, Fat Models

The Rails community has a classic answer: thin controllers, fat models. If the controller knows too much, let the model know it instead. We move checkout into the cart:

class Cart < ApplicationRecord
class CheckoutError < StandardError; end
belongs_to :customer
has_many :items, class_name: "CartItem", dependent: :destroy
def check_out!(coupon_code:, payment_token:)
raise CheckoutError, "Your cart is empty" if items.empty?
raise CheckoutError, "Some products are out of stock" unless in_stock?
charge = PaymentGateway.charge(amount: total(coupon_code), token: payment_token)
raise CheckoutError, charge.error_message unless charge.success?
order = create_order!(coupon_code, charge)
OrderMailer.confirmation(order).deliver_later
items.destroy_all
order
end
# ... in_stock?, total, create_order!
end

And the controller now follows the recipe:

class CheckoutsController < ApplicationController
before_action :authenticate_customer!
def create
order = current_customer.cart.check_out!(
coupon_code: params[:coupon_code], payment_token: params[:payment_token]
)
redirect_to order_path(order), notice: "Thank you for your order!"
rescue Cart::CheckoutError => error
redirect_to cart_path, alert: error.message
end
end

The controller has improved: now it only speaks HTTP. But if we revisit the list of reasons to change, every one except the first is still there. They have just moved to another file.

Now Cart knows about the payment gateway, the mailer, and coupon rules. A model that should represent a cart and its rules coordinates an entire purchase process. Every change to the gateway or email requires us to open, understand, and retest the cart class.

Back to the Controller

Imagine the application keeps growing and two new requirements arrive:

The easiest path is to add options to the existing method:

def check_out!(coupon_code:, payment_token: nil, charge: true, notify: true, coupon_override: nil)
# ...
end

Each flag adds combinations of behavior to understand and test. Eventually, check_out! becomes so complex that the next developer might write the phone order variation directly in Admin::PhoneOrdersController, copying the calculation logic they need.

We would be back to a fat controller, now with a duplicated discount rule too.

The cycle goes from controller to model to slim down the controller, and from model back to controller when the model becomes hard to modify.

The problem is that checkout belongs in neither. As long as we use only those two places, we will keep bouncing between them.

The Way Out: From Fat Models to Services, and From Services to Layers

That third place is useful beyond checkout. Any operation coordinating several pieces faces the same problem, such as a refund that calls the payment gateway and records the transaction.

The First Step: One Object per Operation

The usual answer in the Rails community is to extract a service object. In our case, PlaceOrder represents a customer’s intent to complete a purchase: it receives what it needs and coordinates the steps, without knowing anything about the HTTP request.

class PlaceOrder
class CheckoutError < StandardError; end
def self.call(...) = new(...).call
def initialize(cart:, payment_token:, coupon_code: nil)
@cart = cart
@payment_token = payment_token
@coupon_code = coupon_code
end
def call
raise CheckoutError, "Your cart is empty" if @cart.items.empty?
raise CheckoutError, "Some products are out of stock" unless @cart.in_stock?
charge = PaymentGateway.charge(amount: @cart.total(@coupon_code), token: @payment_token)
raise CheckoutError, charge.error_message unless charge.success?
order = Order.create_from_cart!(@cart, coupon_code: @coupon_code, payment_reference: charge.id)
order.deduct_stock!
OrderMailer.confirmation(order).deliver_later
ActivityLog.record(:order_placed, order)
@cart.clear!
order
end
end

The controller calls the operation and translates its error into an HTTP response:

class CheckoutsController < ApplicationController
before_action :authenticate_customer!
def create
order = PlaceOrder.call(
cart: current_customer.cart,
payment_token: params[:payment_token],
coupon_code: params[:coupon_code]
)
redirect_to order_path(order), notice: "Thank you for your order!"
rescue PlaceOrder::CheckoutError => error
redirect_to cart_path, alert: error.message
end
end

On the surface, this looks much like the controller from the fat models recipe. The difference is behind it: coordination now has an explicit owner, while cart and order rules stay in their models. Cart knows whether it is ready and how much it costs. Order knows how to create itself from a cart and reduce product inventory. And PlaceOrder alone knows the entire process: which steps it includes and in what order.

Phone orders no longer need flags: they are another operation, PlacePhoneOrder, which reuses the same model pieces and decides its own steps. No charge, its own special coupon, and no interference with PlaceOrder. The mobile app calls PlaceOrder from its own controller.

The Catch-All Directory

The problem starts when “service” becomes the default answer to “where do I put this?”:

app/services/
├── place_order.rb
├── refund_order.rb
├── order_search_service.rb
├── checkout_params_service.rb
├── order_json_service.rb
├── can_cancel_order_service.rb
├── shipping_notification_service.rb
├── stripe_service.rb
├── products_filter_service.rb
└── ... (otros 71)

Look at what is in there: one object builds queries, another validates form parameters, another generates JSON, another decides permissions, another sends notifications, and another wraps an external API. Some return relations, others booleans or hashes, and others raise exceptions.

When we call any object that does not fit in a model or controller a service, the name stops describing a role. Everything has a home, but finding a calculation means opening classes that do completely different things, and you cannot predict what any of them takes or returns.

Layers: A Place for Each Responsibility

The way forward requires more than extra directories: each piece needs a role, and those roles need to be grouped into levels. That is layered architecture: dividing the application into horizontal layers, each with a specific responsibility and a single dependency rule: dependencies only point downward. A layer can use lower layers, but never knows anything about higher ones.

We will use the common four-layer map here (though it is not the only valid way to organize a Rails application):

With this map, we can classify the cycle from the beginning:

The way out is the same in both cases: extract coordination into a use case.

The map also explains why an email-sending callback or a model that reads Current causes problems: in both cases, the domain takes on something belonging to another layer.

From the Catch-All Directory to Layers

Almost everything in app/services belongs in a layer, and each piece has a name of its own:

Layer Piece What It Solves in the Store
Domain Query objects Order and product queries buried in large scopes or services
Presentation Form and filter objects Validating checkout parameters and turning catalog filters into queries, each in its own object
Presentation Presenters and serializers Order HTML and JSON, without view logic in Order
Application Authorization policies Who can cancel or refund an order
Application Notifications Emails and shipping notifications, decoupled from the order
Presentation View components Reusable UI pieces for the catalog and cart
Infrastructure Configuration Gateway keys, shipping rates, and other settings in one place

With each piece in its place, app/services can focus on use cases such as PlaceOrder and RefundOrder. Anything that still lacks a clear abstraction needs to be examined by responsibility, rather than by the directory it fits in.

What It Costs

Layers are not free. There are more files, more names to learn, and more conventions for the team to agree on and follow. Tracing a request means moving between more classes. If your application has few entry points and remains inexpensive to change, all of this is overengineering: Rails conventions are enough.

The signal to take this step is the cycle from the beginning: when a new requirement forces you to choose between filling a model with flags and copying logic into another controller. From there, the gains are a predictable place for each responsibility, small and fast tests for each piece, and new entry points (an API, an admin panel, a job) that reuse existing use cases instead of inventing their own.

Test your knowledge

  1. We move all the checkout logic from the controller to Cart#check_out!. What have we actually solved?

  1. Phone orders need a different flow. Why is PlacePhoneOrder better than adding flags to PlaceOrder?

  1. What problem does the article's app/services directory illustrate?

  1. What is the dependency rule in layered architecture?

  1. When is it worth introducing layers?