Rails Concerns and Service Objects
As your Rails application grows, models and controllers tend to accumulate responsibilities they shouldn’t have. Two patterns that help you keep your code organized are Concerns and Service Objects. This guide shows you when and how to use each.
What Are Concerns?
Concerns are modules that extend ActiveSupport::Concern, allowing you to extract shared behavior from models and controllers. They’re particularly useful when you have functionality that crosses model boundaries or when a model becomes too large.
When to Use Concerns
- Shared behavior across multiple models: Authentication status, tagging, caching logic
- Model-specific validations: Custom validation methods that don’t belong elsewhere
- Callback chains: Complicated before/after callbacks that clutter your model
Creating a Concern
Create a concern in app/models/concerns/ or app/controllers/concerns/:
# app/models/concerns/timestampable.rb
module Timestampable
extend ActiveSupport::Concern
included do
before_create :set_timestamps
before_update :mark_updated
end
private
def set_timestamps
self.created_at ||= Time.current
self.updated_at = Time.current
end
def mark_updated
self.updated_at = Time.current
end
end
Include it in your model:
class User < ApplicationRecord
include Timestampable
# ... rest of model
end
The included block runs when the module is mixed into a class, giving you access to the class context.
What Are Service Objects?
Service Objects are plain Ruby objects that encapsulate a specific business operation. They help you move complex logic out of controllers and models, making your code easier to test and understand.
When to Use Service Objects
- Complex workflows that involve multiple models
- Operations that don’t fit neatly into a single model
- Business logic that changes frequently
- Operations that need to be reused across controllers
Creating a Service Object
# app/services/process_order.rb
class ProcessOrder
def initialize(order, payment_gateway:)
@order = order
@payment_gateway = payment_gateway
end
def call
return failure(:invalid_order) unless @order.valid?
payment_result = @payment_gateway.charge(
amount: @order.total,
customer: @order.user.email
)
if payment_result.success?
@order.mark_as_paid!
notify_user
success(@order)
else
failure(payment_result.error)
end
end
private
def notify_user
OrderMailer.order_processed(@order).deliver_later
end
def success(order)
Result.new(success: true, order: order, error: nil)
end
def failure(error)
Result.new(success: false, order: nil, error: error)
end
end
# Simple result object
class Result
attr_reader :order, :error
def initialize(success:, order:, error:)
@success = success
@order = order
@error = error
end
def success?
@success
end
end
Using the Service Object in a Controller
class OrdersController < ApplicationController
def create
order = Order.new(order_params)
processor = ProcessOrder.new(
order,
payment_gateway: StripeGateway.new
)
result = processor.call
if result.success?
redirect_to result.order, notice: "Order processed!"
else
@order = order
flash[:error] = result.error
render :new
end
end
end
Concerns vs Service Objects: When to Use Which
| Scenario | Use |
|---|---|
| Shared model behavior | Concern |
| Cross-cutting concerns | Concern |
| One-off business logic | Concern |
| Complex multi-step operations | Service Object |
| Payment processing, imports, exports | Service Object |
| Logic that needs mocking in tests | Service Object |
Best Practices
For Concerns
- Keep concerns focused on a single responsibility
- Avoid tight coupling to specific models
- Use
delegateto forward method calls when appropriate - Document what behavior the concern adds
For Service Objects
- Give service objects a clear, single purpose
- Use meaningful names:
ProcessOrdernotOrderProcessor - Return consistent result objects
- Inject dependencies (like payment gateways) rather than hardcoding
Testing
Testing Concerns
# spec/models/concerns/timestampable_spec.rb
require 'spec_helper'
RSpec.describe Timestampable do
let(:timestampable_class) do
Class.new do
include Timestampable
attr_accessor :created_at, :updated_at
end
end
subject { timestampable_class.new }
describe '#set_timestamps' do
it 'sets created_at on creation' do
subject.save
expect(subject.created_at).to be_present
end
end
end
Testing Service Objects
# spec/services/process_order_spec.rb
require 'spec_helper'
RSpec.describe ProcessOrder do
let(:order) { double('Order', valid?: true, total: 100, user: user) }
let(:user) { double('User', email: 'test@example.com') }
let(:payment_gateway) { double('PaymentGateway') }
subject { described_class.new(order, payment_gateway: payment_gateway) }
describe '#call' do
context 'when payment succeeds' do
before do
allow(payment_gateway).to receive(:charge)
.and_return(double(success?: true))
allow(order).to receive(:mark_as_paid!)
end
it 'marks order as paid' do
subject.call
expect(order).to have_received(:mark_as_paid!)
end
end
context 'when payment fails' do
before do
allow(payment_gateway).to receive(:charge)
.and_return(double(success?: false, error: 'Card declined'))
end
it 'returns a failure result' do
result = subject.call
expect(result).not_to be_success
expect(result.error).to eq('Card declined')
end
end
end
end
See Also
- Rails Middleware — Understanding the Rails request/response pipeline
- ActiveJob and Sidekiq — Background job processing in Rails
- Building APIs with Rails API Mode — Lightweight API backends