"Fat Model, Skinny Controller" is a very good first step, but it doesn't scale well once your codebase starts to grow.
Let's think on the Single Responsibility of models. What is the single responsibility of models? Is it to hold business logic? Is it to hold non-response-related logic?
No. Its responsibility is to handle the persistence layer and its abstraction.
Business logic, as well as any non-response-related logic and non-persistence-related logic, should go in domain objects.
Domain objects are classes designed to have only one responsibility in the domain of the problem. Let your classes "Scream Their Architecture" for the problems they solve.
In practice, you should strive towards skinny models, skinny views and skinny controllers. The architecture of your solution shouldn't be influenced by the framework you're choosing.
For example
Let's say you're a marketplace which charges a fixed 15% commission to your customers via Stripe. If you charge a fixed 15% commission, that means that your commission changes depending on the order's amount because Stripe charges 2.9% + 30¢.
The amount you charge as commission should be: amount*0.15 - (amount*0.029 + 0.30)
.
Don't write this logic in the model:
# app/models/order.rb
class Order < ActiveRecord::Base
SERVICE_COMMISSION = 0.15
STRIPE_PERCENTAGE_COMMISSION = 0.029
STRIPE_FIXED_COMMISSION = 0.30
...
def commission
amount*SERVICE_COMMISSION - stripe_commission
end
private
def stripe_commission
amount*STRIPE_PERCENTAGE_COMMISSION + STRIPE_FIXED_COMMISSION
end
end
As soon as you integrate with a new payment method, you won't be able to scale this functionality inside this model.
Also, as soon as you start to integrate more business logic, your Order
object will start to lose cohesion.
Prefer domain objects, with the calculation of the commission completely abstracted from the responsibility of persisting orders:
# app/models/order.rb
class Order < ActiveRecord::Base
...
# No reference to commission calculation
end
# lib/commission.rb
class Commission
SERVICE_COMMISSION = 0.15
def self.calculate(payment_method, model)
model.amount*SERVICE_COMMISSION - payment_commission(payment_method, model)
end
private
def self.payment_commission(payment_method, model)
# There are better ways to implement a static registry,
# this is only for illustration purposes.
Object.const_get("#{payment_method}Commission").calculate(model)
end
end
# lib/stripe_commission.rb
class StripeCommission
STRIPE_PERCENTAGE_COMMISSION = 0.029
STRIPE_FIXED_COMMISSION = 0.30
def self.calculate(model)
model.amount*STRIPE_PERCENTAGE_COMMISSION
+ STRIPE_PERCENTAGE_COMMISSION
end
end
# app/controllers/orders_controller.rb
class OrdersController < ApplicationController
def create
@order = Order.new(order_params)
@order.commission = Commission.calculate("Stripe", @order)
...
end
end
Using domain objects has the following architectural advantages:
amount
.User
object in each Ruby on Rails application.I personally like to put domain objects in lib
. If you do so, remember to add it to autoload_paths
:
# config/application.rb
config.autoload_paths << Rails.root.join('lib')
You may also prefer to create domain objects more action-oriented, following the Command/Query pattern. In such case, putting these objects in app/commands
might be a better place as all app
subdirectories are automatically added to the autoload path.