Refactoring fat models in Rails: delegate extract and compose
Introduction
Reduce complexity in Rails models with the method delegation, extraction, and composition.
Initial Fat Model Example
Added comments to explain how we could group these methods
# app/models/order.rb
class Order < ActiveRecord::Base
# Various state finders
def find_purchased
end
def find_waiting_for_review
end
def find_waiting_for_sign_off
end
# Search methods
def advanced_search(fields, options = {})
end
def simple_search(terms)
end
# Export methods
def to_xml
end
def to_json
end
def to_csv
end
def to_pdf
end
end
This model is overwhelmingly cumbersome and violates the Single Responsibility Principle by handling multiple aspects of the order process.
Refactoring using delegate
Method
Firstly, we can extract the conversion methods into a separate class and then use Rails’ delegation mechanism to keep the interface of the Order
model clean.
Creating the New OrderConverter
Class
The responsibility of this class would be to convert the model into XML, JSON, CSV, and PDF, as the initial model was doing.
# app/models/order_converter.rb
# you can also move this to /lib or /concerns
class OrderConverter
attr_reader :order
def initialize(order)
@order = order
end
def to_xml
end
def to_json
end
def to_csv
end
def to_pdf
end
end
Updated Order
model with Delegation
Let’s use the delegate method to orchestrate the creation of all methods in our Order.rb model.
# app/models/order.rb
class Order < ActiveRecord::Base
delegate :to_xml, :to_json, :to_csv, :to_pdf, to: :converter
def converter
OrderConverter.new(self)
end
end
With this setup, calls like @order.to_pdf
seamlessly forward to OrderConverter
, adhering to both the Single Responsibility and Law of Demeter principles.
Extracting Logic into a Separate Class
Another common refactor is moving related functionality into service classes, which can then be composed back into the main model.
Example: A Complex BankAccount
Model
# app/models/bank_account.rb
class BankAccount < ActiveRecord::Base
validates :balance_in_cents, presence: true
validates :currency, presence: true
def balance_in_other_currency(currency)
# Logic to convert in other currencies
end
def balance
balance_in_cents / 100
end
def balance_equal?(other_bank_account)
balance_in_cents == other_bank_account.balance_in_other_currency(currency)
end
end
This model improperly handles diverse functionalities.
Using composed_of
for Money Management
By introducing a Money
class and linking it via composed_of
, BankAccount
responsibilities are clearly divided.
# app/models/bank_account.rb
class BankAccount < ActiveRecord::Base
validates :balance_in_cents, presence: true
validates :currency, presence: true
composed_of :balance,
class_name: "Money",
mapping: [%w(balance_in_cents amount_in_cents),
%w(currency currency)]
end
# app/models/money.rb
class Money
include Comparable
attr_accessor :amount_in_cents, :currency
def initialize(amount_in_cents, currency)
self.amount_in_cents = amount_in_cents
self.currency = currency
end
def in_currency(other_currency)
# Logic to convert in other currencies
end
def amount
amount_in_cents / 100
end
def <=>(other_money)
amount_in_cents <=> other_money.in_currency(currency).amount_in_cents
end
end
Controller Usage
@bank_account.balance.in_currency(:eur)
This keeps the BankAccount
model lean and delegates monetary calculations to the Money
class.
Likewise, you can compare the balances by using @bank_account.balance > @other_bank_account.balance
, regardless of which currencies the bank accounts are using.
Conclusion
Refactoring fat models in Ruby on Rails through meticulous extraction and composition leads to cleaner, more maintainable codebases.
This approach not only aligns with SOLID principles but also enhances the flexibility and robustness of the application architecture.
Related posts
Building a Rails Engine