rubyguides

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

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

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