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 Rails concerns and service objects. This tutorial shows you when and how to use each, from extracting shared model behaviour to isolating complex business operations.
Before you start
Rails applications tend to grow organically, and as they do, models and controllers pick up responsibilities that do not really belong to them. Two patterns that help you keep the codebase healthy are Rails concerns (for shared behaviour) and service objects (for complex business operations). Knowing when to reach for each one makes the difference between a codebase that stays manageable and one that turns into a maze of tangled responsibilities.
Key takeaways
- Concerns are best when several models or controllers share a small, focused piece of behaviour.
- Service objects are best when one operation coordinates several steps or several collaborators.
- A pattern is only helpful when it makes the code easier to read, test, and change.
- The right choice depends more on the shape of the work than on the popularity of the pattern.
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
The included block runs when the module is mixed into a class, giving you access to the class context. Concerns are handy when the behaviour is tightly related to the class that includes them. A timestamp helper, a shared scope, or a reusable callback chain can all fit here. The trick is to keep the module narrow. If the concern starts growing into a mini-application, it is probably time to split the behaviour into smaller pieces or move the orchestration somewhere else.
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
Service objects work best when there is a clear input, a clear output, and a clear sequence of steps in between. That makes them easy to test without a controller, and it makes the business rule easier to read because the method name usually tells the whole story. A small service object can be a better home for orchestration than a model that is already busy validating, querying, and persisting data.
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
One good rule is to keep concerns focused on reusable behaviour and keep service objects focused on one business action. A concern might add a concern-specific scope or callback to a model. A service object might charge a card, create a record, and send a mailer. If you start mixing the two, the code becomes harder to explain because the responsibilities blur together.
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
Choosing between concerns and service objects
When the code is mostly about reuse inside a model or controller, a concern is usually the lighter option. It can keep the shared behaviour close to the place where it is used without forcing you to create another class. That works well for validations, scopes, and small helper methods that belong to the model layer.
When the code coordinates several actions, a service object is usually the clearer choice. It can accept a few dependencies, run a process from start to finish, and return a simple result object that the controller or job can inspect. That separation makes it easier to test the sequence without pulling in the whole request cycle.
If you are unsure, ask one practical question: “Is this mainly shared behaviour, or is it mostly a business operation?” Shared behaviour points toward a concern. Business operation points toward a service object. That question is usually enough to keep the codebase from drifting into a pile of overlapping abstractions.
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
Common mistakes
- Putting too much business logic into a concern because it seems convenient at the time.
- Creating a service object for a trivial single-line method that does not need its own class.
- Letting controllers decide too much of the workflow instead of handing the work to one object.
- Returning inconsistent result shapes, which makes the caller handle too many edge cases.
Frequently asked questions
Can a concern call a service object?
Yes. A concern can expose shared behaviour and then call a service object when the action needs orchestration. That is often cleaner than putting the whole workflow inside the concern itself.
Should every app use service objects?
No. If a Rails action is short and clear, a service object may add more ceremony than value. Use the pattern when the workflow is getting hard to follow or test directly.
Are concerns always bad?
Not at all. Concerns are helpful when they stay small and focused. They become risky when they become a catch-all for unrelated methods, callbacks, and helper logic.
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
Next steps
With concerns and service objects in your toolkit, the next layer to understand is how Rails processes requests before they reach your controllers. The Rails middleware tutorial shows how the middleware stack wraps every request, and the ActiveJob and Sidekiq tutorial covers background processing for long-running operations.
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