It’s all too easy for ActiveRecord models to balloon into monolithic “God objects.” You start by adding a validation here, a callback there, maybe a little business logic in a class method—and before you know it, your User
or Order
model is hundreds of lines long, has dozens of dependencies, and is terrifying to test or extend.
In this post we’ll explore how to tame “fat models” by extracting business logic into plain-old Ruby service classes—sometimes called service objects—and organizing them in a service-oriented architecture. We’ll walk through a concrete example of refactoring a bloated model into focused, single-responsibility components, showing the before-and-after code and discussing the benefits for maintainability, testability, and overall code clarity.
The Problem: A Bloated ActiveRecord Model
Imagine you have an Order
model in a Rails e-commerce app. Over time, you’ve added validations, price-calculations, notification callbacks, payment integrations, and more—directly in the model:
# app/models/order.rb
class Order < ApplicationRecord
belongs_to :user
has_many :line_items, dependent: :destroy
validate :must_have_at_least_one_item
before_save :calculate_totals
after_create :send_confirmation_email, :charge_credit_card
def total_price
line_items.sum { |li| li.quantity * li.unit_price }
end
def calculate_totals
self.subtotal = total_price
self.tax = subtotal * 0.08
self.total = subtotal + tax + shipping_cost
end
def send_confirmation_email
NotificationMailer.order_confirmation(self).deliver_later
end
def charge_credit_card
if payment_token.present?
response = Stripe::Charge.create(
amount: (total * 100).to_i,
currency: "usd",
source: payment_token,
metadata: { order_id: id }
)
update!(stripe_charge_id: response.id)
else
errors.add(:base, "No payment token provided")
raise ActiveRecord::RecordInvalid, self
end
end
private
def must_have_at_least_one_item
errors.add(:base, "Order must have at least one line item") if line_items.empty?
end
end
This model is doing everything: data validation, business calculations, external API calls, mailing, error handling. Tests for Order
become slow and brittle, as you must stub Stripe, mailers, potentially clear side effects—and every change feels risky.
The Solution: Service-Oriented Architecture
The core idea is: keep your models thin—they should manage persistence concerns (relationships, validations, scopes) but delegate business workflows to dedicated service classes. Each service class:
- Is a plain Ruby object (PORO).
- Has a single responsibility.
- Exposes a clear interface, e.g. a
.call
class method. - Lives under
app/services
.
Directory Structure
app/
├── models/
│ └── order.rb # only relations & basic persistence
└── services/
├── order/
│ ├── calculate_totals.rb
│ ├── send_confirmation_email.rb
│ └── charge_credit_card.rb
└── create_order.rb
Step 1: Extracting calculate_totals
Move pricing logic into its own service:
# app/services/order/calculate_totals.rb
module Order
class CalculateTotals
def initialize(order)
@order = order
end
def call
@order.subtotal = @order.line_items.sum { |li| li.quantity * li.unit_price }
@order.tax = @order.subtotal * tax_rate
@order.total = @order.subtotal + @order.tax + @order.shipping_cost
@order
end
private
def tax_rate
0.08
end
end
end
In your model, simply invoke:
# app/models/order.rb
class Order < ApplicationRecord
before_save -> { Order::CalculateTotals.new(self).call }
# ... relations & validations only ...
end
Step 2: Extracting Notifications and Payments
Similarly, pull the mailer and payment handling out:
# app/services/order/send_confirmation_email.rb
module Order
class SendConfirmationEmail
def initialize(order)
@order = order
end
def call
NotificationMailer.order_confirmation(@order).deliver_later
end
end
end
# app/services/order/charge_credit_card.rb
module Order
class ChargeCreditCard
def initialize(order, token:)
@order = order
@token = token
end
def call
raise ArgumentError, "No payment token provided" if @token.blank?
charge = Stripe::Charge.create(
amount: (@order.total * 100).to_i,
currency: "usd",
source: @token,
metadata: { order_id: @order.id }
)
@order.update!(stripe_charge_id: charge.id)
rescue Stripe::StripeError => e
Rails.logger.error("Charge failed: #{e.message}")
raise
end
end
end
And in Order
:
# app/models/order.rb
class Order < ApplicationRecord
after_create do
Order::SendConfirmationEmail.new(self).call
Order::ChargeCreditCard.new(self, token: payment_token).call
end
end
Step 3: Orchestrating with a “CreateOrder” Service
Rather than sprinkling callbacks in the model, you can build a single entry point to orchestrate the entire workflow:
# app/services/create_order.rb
class CreateOrder
Result = Struct.new(:order, :success?, :errors)
def initialize(user:, line_items:, payment_token:)
@user = user
@line_items = line_items
@payment_token = payment_token
end
def call
order = build_order
if order.save
Order::CalculateTotals.new(order).call
order.save!
Order::ChargeCreditCard.new(order, token: @payment_token).call
Order::SendConfirmationEmail.new(order).call
Result.new(order, true, nil)
else
Result.new(order, false, order.errors.full_messages)
end
rescue => e
Result.new(order, false, [e.message])
end
private
def build_order
Order.new(user: @user).tap do |o|
@line_items.each { |attrs| o.line_items.build(attrs) }
o.payment_token = @payment_token
end
end
end
Then in your controller:
# app/controllers/orders_controller.rb
def create
service = CreateOrder.new(
user: current_user,
line_items: order_params[:line_items],
payment_token: order_params[:payment_token]
)
result = service.call
if result.success?
redirect_to result.order, notice: "Order created!"
else
flash.now[:alert] = result.errors.join(", ")
render :new
end
end
Benefits of This Approach
-
Single Responsibility Each class does one thing—no more juggling mailers, payments, and persistence in a single model.
-
Improved Testability You can write focused unit tests for
Order::ChargeCreditCard
with minimal setup, stubbing only Stripe. TheCreateOrder
orchestrator can be tested end-to-end without worrying about every callback firing unexpectedly. -
Clearer Workflow The
CreateOrder
service reads like a script of your business process. It’s obvious in what order you calculate totals, charge the card, and send an email. -
Easier Maintenance Need to change the tax rate? Open
app/services/order/calculate_totals.rb
. Want to swap Stripe for another gateway? ReplaceOrder::ChargeCreditCard
payload with your new API client—your model remains untouched. -
Runtime Flexibility You can instantiate and call services anywhere: from controllers, background jobs, or console scripts, without dragging in the full ActiveRecord lifecycle.
Conclusion
Refactoring fat models into a service-oriented architecture doesn’t require a radical rewrite—just a disciplined extraction of responsibilities and a clear folder structure under app/services
. You’ll end up with thin models, better-organized business logic, and faster, more reliable tests. Next time your Rails model approaches “God object” territory, remember: a small service object can save you a world of pain.
Happy refactoring! 🚀