"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 service objects.
Service 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_PERCENTAGE_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 service 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 a service object has the following architectural advantages. It:
- is extremely easy to unit test, as no fixtures or factories are required to instantiate the objects with the logic.
- works with everything that accepts the message
amount
. - keeps each service object small, with clearly defined responsibilities, and with higher cohesion.
- easily scales with new payment methods by addition, not modification.
- stops the tendency to have an ever-growing
User
object in each Ruby on Rails application.