Rails Concerns and Service Objects

· 3 min read · Updated March 17, 2026 · intermediate
rails concerns service-objects rails-patterns architecture ruby

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

ScenarioUse
Shared model behaviorConcern
Cross-cutting concernsConcern
One-off business logicConcern
Complex multi-step operationsService Object
Payment processing, imports, exportsService Object
Logic that needs mocking in testsService Object

Best Practices

For Concerns

  • Keep concerns focused on a single responsibility
  • Avoid tight coupling to specific models
  • Use delegate to forward method calls when appropriate
  • Document what behavior the concern adds

For Service Objects

  • Give service objects a clear, single purpose
  • Use meaningful names: ProcessOrder not OrderProcessor
  • 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