When Rails Conventions Are Not Enough
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!" endendPaymentGateway 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:
- How we respond to the browser changes (redirects, flash messages).
- Inventory, coupon, or pricing rules change.
- The payment gateway, confirmation email, or what happens to the cart changes.
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!endAnd 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 endendThe 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 mobile app needs an API checkout using the customer’s saved card.
- The customer service team wants to create phone orders with cash on delivery (the customer pays when the order arrives) and a coupon only agents can apply.
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) # ...endEach 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 endendThe 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 endendOn 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):
- Presentation: everything that communicates with the outside world. Controllers, views, and objects that translate what comes in and goes out: form or filter parameters on the way in, and API JSON on the way out.
- Application: use cases such as
PlaceOrderorRefundOrder. Each decides which steps the operation includes, who can execute it, and who needs to be notified when it finishes. It coordinates the use case and delegates rules to the domain. - Domain: business concepts and their rules.
Order,Cart,Product, and queries that express business questions. - Infrastructure: the things that support the business without being the business itself. The database, the payment gateway, the email delivery service (an SMTP server or an external provider), and configuration.
With this map, we can classify the cycle from the beginning:
- The fat controller was the presentation layer doing the work of the application and domain layers.
Cart#check_out!was the domain doing the application’s work.
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
-
We move all the checkout logic from the controller to
Cart#check_out!. What have we actually solved?
-
Phone orders need a different flow. Why is
PlacePhoneOrderbetter than adding flags toPlaceOrder?
-
What problem does the article's
app/servicesdirectory illustrate?
-
What is the dependency rule in layered architecture?
-
When is it worth introducing layers?