Introduction
As Rails applications grow, models accumulate business logic that does not belong there. Service objects encapsulate multi-step operations involving multiple models or external services. Form objects handle complex forms that span multiple models. Both patterns keep your models focused on data access and validation.
Key Concepts
- Service object: A plain Ruby class that encapsulates a single business operation (e.g., checkout, registration, import).
- Form object: A class that includes
ActiveModel::Modelto handle form data that spans multiple models. - Single Responsibility: Each service object does one thing. Name it as a verb:
CreateOrder,ProcessPayment,ImportUsers. ActiveModel::Model: A mixin that gives plain classes validation, error handling, and form builder compatibility.
Real World Context
A checkout process touches orders, payments, inventory, notifications, and analytics. Putting all this in Order#save callbacks makes the model untestable and fragile. A CheckoutService class is explicit, testable, and easy to reason about. Every mature Rails codebase uses service objects for complex operations.
Deep Dive
Service Objects
ruby# app/services/order_checkout_service.rb class OrderCheckoutService def initialize(order, payment_params) @order = order @payment_params = payment_params end def call ActiveRecord::Base.transaction do validate_inventory! process_payment! create_shipment! send_notifications @order end rescue PaymentError => e @order.errors.add(:base, e.message) false end private def validate_inventory! @order.line_items.each do |item| unless item.product.in_stock?(item.quantity) raise InventoryError, "#{item.product.name} is out of stock" end end end def process_payment! payment = PaymentGateway.charge( amount: @order.total, card: @payment_params ) unless payment.success? raise PaymentError, payment.error_message end @order.update!(payment_id: payment.id, status: "paid") end def create_shipment! @order.create_shipment!( address: @order.shipping_address, items: @order.line_items ) end def send_notifications OrderMailer.confirmation(@order).deliver_later end end # Usage in controller result = OrderCheckoutService.new(@order, payment_params).call
The service wraps everything in a transaction. If any step fails, the entire operation rolls back.
Form Objects
ruby# app/forms/registration_form.rb class RegistrationForm include ActiveModel::Model include ActiveModel::Attributes attribute :email, :string attribute :password, :string attribute :company_name, :string attribute :terms_accepted, :boolean validates :email, presence: true, format: { with: URI::MailTo::EMAIL_REGEXP } validates :password, presence: true, length: { minimum: 8 } validates :company_name, presence: true validates :terms_accepted, acceptance: true def save return false unless valid? ActiveRecord::Base.transaction do @company = Company.create!(name: company_name) @user = User.create!( email: email, password: password, company: @company, role: "admin" ) end true rescue ActiveRecord::RecordInvalid => e errors.add(:base, e.message) false end def user @user end end
Form objects validate all inputs before touching the database, and they work seamlessly with Rails form builders.
When to Use Each Pattern
| Pattern | Use When |
|---|---|
| Scope | Simple, reusable query conditions |
| Query Object | Complex queries with many parameters |
| Service Object | Multi-step operations, external services |
| Form Object | Forms spanning multiple models |
Common Pitfalls
- Service objects that do too much -- A service should do one operation. If it grows beyond 100 lines, split it.
- Skipping validations in service objects -- Always validate inputs before executing. Use
valid?or form objects. - Not wrapping in transactions -- Multi-model operations must be wrapped in
ActiveRecord::Base.transactionto ensure atomicity.
Best Practices
- Name services as verbs --
ProcessPayment,SendInvitation,ImportCSVclearly communicate intent. - Return truthy/falsy or a result object -- Consistent return values make controller logic simple.
- Keep the
callmethod short -- It should read like a table of contents. Extract steps into private methods.
Summary
- Service objects encapsulate multi-step business operations.
- Form objects handle forms that span multiple models.
- Both patterns keep models thin and controllers simple.
- Wrap multi-model operations in transactions.
- Name services as verbs and keep the
callmethod short.
Code Examples
class RegistrationForm
include ActiveModel::Model
include ActiveModel::Attributes
attribute :email, :string
attribute :password, :string
attribute :company_name, :string
validates :email, presence: true
validates :password, length: { minimum: 8 }
def save
return false unless valid?
ActiveRecord::Base.transaction do
@company = Company.create!(name: company_name)
@user = User.create!(email: email, password: password, company: @company)
end
true
end
end