Ruby on Rails Rails Best Practices Domain Objects (No More Fat Models)

Help us to keep this website almost Ad Free! It takes only 10 seconds of your time:
> Step 1: Go view our video on YouTube: EF Core Bulk Extensions
> Step 2: And Like the video. BONUS: You can also share it!

Example

"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:

  • it's 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 domain 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.

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.



Got any Ruby on Rails Question?