• Home
  • About
  • Portfolio
  • Contact
CodeCurious
  • Home
  • About
  • Portfolio
  • Contact
Go Back

Ruby On Rails Service Objects Explained (2026 Guide)

Learn how Ruby on Rails service objects improve code organization, testing, and maintainability with practical examples.

Jean Emmanuel Cadet
By Jean Emmanuel Cadet • Ruby on Rails Developer

Last updated : Jul 29, 2026 • 27 min read

Ruby on Rails Service Objects Explained (2026 Guide)

Last updated : Jul 29, 2026 • 27 min read

Share with friends

Every Rails application starts out clean. You generate a model, add a controller action, write a bit of validation, and everything fits neatly into the framework's conventions. Then the application grows. New features get bolted onto existing controllers. Models pick up more callbacks, more validations, and more methods until a single class file stretches past a thousand lines. Suddenly a change that should take twenty minutes takes an afternoon, because nobody is quite sure what will break.

This is one of the most common growing pains in Rails development, and it has a name: fat models and fat controllers. Rails gives you a lot of structure out of the box, but it does not tell you where business logic should live once it grows beyond a few lines. Developers often default to stuffing that logic into whichever class is closest at hand, usually the controller or the model, because that is what the framework nudges you toward.

Service Objects are one of the most effective ways to solve this problem. A Service Object gives your business logic a dedicated home outside of the request/response cycle and outside of your Active Record models, so each part of your application can focus on what it does best. Controllers handle HTTP concerns. Models handle persistence and data integrity. Service Objects handle the actual work your application is trying to accomplish.

In this article, you will learn what Service Objects are, why they matter, and when to use them. You will build a Service Object from scratch, walk through the most common patterns used in real Rails codebases, learn how to organize services as your app scales, and see two full refactoring examples that turn a fat controller and a fat model into clean, testable code. By the end, you will have a practical framework for deciding where business logic belongs in your own Rails 8 applications.


What Are Service Objects?

A Service Object is a plain Ruby class that encapsulates a single piece of business logic. It is not a special Rails construct. Rails does not generate them for you, and there is no ApplicationService base class shipped with the framework. A Service Object is simply a Plain Old Ruby Object, often shortened to PORO, that you create yourself and place somewhere Rails can autoload it, typically in app/services.

The word "service" describes what the object does: it performs a service, or an action, on behalf of the rest of the application. Instead of a controller reaching into a model and orchestrating several steps directly, it hands the work off to a service, which knows how to carry out that specific task from start to finish.

How Service Objects Differ From Models, Controllers, and Helpers

It helps to think about each part of a Rails application in terms of responsibility:

  • Models are responsible for data. They validate attributes, define associations, and know how to persist and retrieve themselves from the database.
  • Controllers are responsible for HTTP. They accept a request, figure out what the user wants, delegate the actual work, and return a response.
  • Helpers are responsible for view logic. They format data for display and keep templates free of complex Ruby code.
  • Service Objects are responsible for business logic. They coordinate the steps needed to complete an action, which often touches multiple models, external APIs, background jobs, and more.

None of these four roles overlap cleanly. A model should not know how to send a welcome email. A controller should not contain the step-by-step logic for processing a refund. A helper should not touch the database. When you introduce Service Objects, you give business logic a place to live that is not artificially squeezed into one of the other three roles.

The Single Responsibility Principle

Service Objects are a practical application of the single responsibility principle: a class should have one reason to change. A well-written service does one thing. RegisterUser registers a user. ProcessRefund processes a refund. ImportProductsFromCsv imports products from a CSV file. Each of these classes has a narrow, well-defined job, which makes them easy to read, easy to test, and easy to reason about in isolation.


Why You Should Use Service Objects

The benefits of Service Objects become obvious once you have worked in a codebase without them. Here are the most significant advantages.

Cleaner Controllers

Controllers should be thin. Their job is to receive a request, call something that does the work, and render a response. When business logic lives in the controller, actions grow long and start handling concerns that have nothing to do with HTTP, like calculating shipping costs or formatting a notification message. Moving that logic into a service keeps controller actions short, usually five to ten lines, and easy to scan at a glance.

Smaller Models

Active Record models tend to accumulate methods over time, especially when developers reach for callbacks to trigger side effects like sending emails or creating related records. This leads to models that are hard to test in isolation, because saving a record might trigger a cascade of unrelated behavior. Service Objects give you a place to put that behavior outside the model, so the model can focus on validations, associations, and scopes.

Better Separation of Concerns

When each class has a clear responsibility, it is much easier to find code. If you are debugging an issue with order confirmation emails, you know to look in a service like Orders::SendConfirmationEmail rather than scrolling through an Order model looking for a callback buried among unrelated methods.

Easier Testing

Service Objects are easy to test because they are just Ruby objects. You do not need to spin up a full request/response cycle like you do when testing controllers, and you do not need to worry about unrelated callbacks firing when you save a record for a model test. You can instantiate the service directly, call it, and assert on the result.

Improved Maintainability

Because services are isolated and focused, changing one piece of business logic rarely requires touching unrelated code. This reduces the risk of regressions and makes onboarding easier for new developers, since the codebase reads more like a description of what the application does.

Better Code Reuse

The same service can be called from a controller action, a background job, a rake task, or a console session. If your registration logic lives in RegisterUser, you can call it from a web signup form and from an admin console script without duplicating any code.

Easier Collaboration Within Teams

When business logic is organized into discrete, well-named services, it becomes much easier for multiple developers to work on different parts of an application without stepping on each other. One developer can work on Checkout::ApplyDiscount while another works on Checkout::CalculateShipping, and the two changes rarely conflict.

Here is a small example that shows the difference clearly. Before introducing a service, a controller might look like this:

class OrdersController < ApplicationController
def create
@order = current_user.orders.build(order_params)

if @order.save
@order.update(status: "confirmed")
InventoryService.decrement_stock(@order)
OrderMailer.confirmation(@order).deliver_later
redirect_to @order, notice: "Order placed successfully."
else
render :new, status: :unprocessable_entity
end
end
end

After extracting a service, the same action becomes much simpler:

class OrdersController < ApplicationController
def create
result = Orders::Create.call(user: current_user, params: order_params)

if result.success?
redirect_to result.order, notice: "Order placed successfully."
else
@order = result.order
render :new, status: :unprocessable_entity
end
end
end

The controller no longer needs to know about inventory, confirmation emails, or order statuses. That knowledge now lives in Orders::Create, where it belongs.


When Should You Use Service Objects?

Service Objects shine whenever an action involves more than a single, simple database write. Some of the most common scenarios include:

  • User registration, where you might create a user record, send a welcome email, and set up default preferences in one coordinated step.
  • Checkout workflows, which often involve validating a cart, applying discounts, calculating totals, and creating an order.
  • Payment processing, which typically involves calling a third-party API, handling failures gracefully, and updating records based on the result.
  • Sending emails, especially when the logic for deciding who receives an email and what it contains is more complex than a single mailer call.
  • File uploads, particularly when you need to validate, process, and store a file, then update related records.
  • Background jobs, where the job itself should stay thin and delegate the actual work to a service, making that logic reusable outside of the job.
  • Third-party API integrations, such as syncing data with a CRM or fetching shipping rates from a carrier.
  • Report generation, where you gather data from multiple sources and format it into a specific structure.
  • Data imports, such as parsing a CSV file and creating or updating many records at once.
  • Complex Active Record transactions, where multiple models need to be created or updated together, and any failure should roll back the entire operation.

When a Service Object Is Unnecessary

Not every action needs a Service Object. If a controller action does nothing more than create or update a single record with no side effects, a Service Object adds unnecessary indirection. For example:

def create
@comment = @post.comments.build(comment_params)
@comment.save
redirect_to @post
end

This is simple enough to stay exactly where it is. A good rule of thumb is to ask whether the action coordinates multiple steps, touches more than one model, or involves logic that you would want to reuse or test independently. If the answer is no, keep it simple. Introducing services for every trivial action creates more files to navigate without any real benefit, and it is one of the most common mistakes teams make when adopting this pattern, which we will cover later in this article.


Building Your First Service Object

Let's build a complete, working example from scratch: a service that registers a new user, sends a welcome email, and creates a default profile record.

Folder Structure

Rails does not include an app/services directory by default, but it will autoload any folder you create under app, thanks to Zeitwerk. Start by creating the directory:

app/
services/
register_user.rb

The Service Class

# app/services/register_user.rb
class RegisterUser
Result = Struct.new(:success?, :user, :errors, keyword_init: true)

def self.call(...)
new(...).call
end

def initialize(email:, password:, name:)
@email = email
@password = password
@name = name
end

def call
user = User.new(email: @email, password: @password, name: @name)

if user.save
create_profile(user)
send_welcome_email(user)
Result.new(success?: true, user: user, errors: [])
else
Result.new(success?: false, user: user, errors: user.errors.full_messages)
end
end

private

def create_profile(user)
Profile.create!(user: user, display_name: user.name)
end

def send_welcome_email(user)
UserMailer.welcome(user).deliver_later
end
end

Let's walk through what each part does.

The Result struct gives the service a predictable return value. Instead of returning a raw boolean or a user object that might or might not be valid, the service always returns an object with a success? flag, the user record, and any errors. This makes it easy for calling code to branch on the outcome without guessing what was returned.

The self.call(...) class method is a convenience wrapper. It forwards any arguments to new and immediately calls the resulting instance. This lets callers write RegisterUser.call(email: ..., password: ..., name: ...) instead of instantiating the class themselves.

The initialize method accepts keyword arguments and stores them as instance variables. Using keyword arguments here, rather than a hash or positional arguments, makes the service's public interface self-documenting. Anyone reading the calling code can see exactly what the service needs.

The call instance method contains the actual logic: build the user, attempt to save it, and depending on the outcome, either complete the additional steps and return a successful result, or return a failure result with the validation errors attached.

The Controller

# app/controllers/registrations_controller.rb
class RegistrationsController < ApplicationController
def create
result = RegisterUser.call(
email: params[:email],
password: params[:password],
name: params[:name]
)

if result.success?
session[:user_id] = result.user.id
redirect_to dashboard_path, notice: "Welcome to the app!"
else
@errors = result.errors
render :new, status: :unprocessable_entity
end
end
end

Notice that the controller has no idea a profile is being created or an email is being sent. It only cares whether the registration succeeded or failed. This is exactly the separation of concerns Service Objects are meant to provide. Since this controller is accepting raw params directly from user input, it is worth pairing this pattern with strong, explicit parameter filtering. If you have not reviewed how Rails handles this, our guide on understanding strong parameters in Rails is a good companion piece before you wire up services that consume request data directly.

The Model

# app/models/user.rb
class User < ApplicationRecord
has_secure_password
has_one :profile, dependent: :destroy

validates :email, presence: true, uniqueness: true
validates :name, presence: true
end

The model stays focused on what it should be responsible for: validations and the association to Profile. Nothing about sending emails or coordinating multi-step logic lives here.

The Routes

# config/routes.rb
Rails.application.routes.draw do
resource :registration, only: [:new, :create]
end

Usage Example

Because the service is a plain Ruby object, you can call it from anywhere, not just a controller:

# From a Rails console
result = RegisterUser.call(email: "[email protected]", password: "secret123", name: "Jamie")
result.success? # => true
result.user.name # => "Jamie"

This flexibility is one of the biggest wins of extracting logic into a service. The exact same class powers your web signup form, an admin tool, a rake task, or a test suite.


Common Service Object Patterns

There are several established patterns for structuring Service Objects. None of them is objectively "correct," and most Rails teams settle on one pattern and use it consistently across the codebase.

The call Class Method Pattern

This is the pattern used in the example above. The class exposes a self.call method that instantiates the object and immediately runs it.

class ChargeCustomer
def self.call(...)
new(...).call
end

def initialize(customer:, amount:)
@customer = customer
@amount = amount
end

def call
# charge logic
end
end

Advantages: Simple, minimal boilerplate, easy to call from anywhere with ServiceName.call(...).

Disadvantages: If a service needs multiple public methods, this pattern can feel awkward, since everything is funneled through a single entry point.

Initialize Plus Call

A slightly more explicit variant skips the class method and requires callers to instantiate the object first:

service = ChargeCustomer.new(customer: customer, amount: amount)
result = service.call

Advantages: Makes it clearer that you are working with an object instance, which can be useful if the service exposes additional methods or state after execution.

Disadvantages: Slightly more verbose at the call site.

Result Objects

Rather than returning true, false, or raising exceptions, a service returns a dedicated result object, as shown in the RegisterUser example. This pattern pairs well with either of the two above.

Result = Struct.new(:success?, :data, :errors, keyword_init: true)

Advantages: Predictable interface for callers, easy to test, avoids relying on exceptions for expected failure cases like validation errors.

Disadvantages: Requires a bit more setup, and teams need to agree on a consistent Result shape across services.

Command Pattern

The command pattern treats each service as a self-contained command object with a single execute or call method, often used in systems where commands might be queued, logged, or undone. This is conceptually very close to the call class method pattern, but the terminology emphasizes that the object represents an action to be taken rather than a general-purpose service.

Interactor Pattern

The interactor pattern, popularized by gems like interactor, structures services as small steps that can be chained together into an organized pipeline, often with built-in support for rolling back previous steps if a later one fails. This is useful for complex workflows like checkout, where several discrete operations need to happen in sequence and any failure partway through should undo what came before.

class Checkout
include Interactor::Organizer

organize ValidateCart, ApplyDiscount, ChargeCustomer, CreateOrder
end

Advantages: Great for multi-step workflows, built-in rollback support, encourages small, focused steps.

Disadvantages: Adds a dependency and a bit of a learning curve, and can feel like overkill for simpler services.

For most Rails applications, starting with the call class method combined with Result objects covers the vast majority of use cases. Reach for an interactor-style gem only once you have several workflows that genuinely need multi-step orchestration.


Organizing Service Objects

As an application grows, app/services can quickly fill up with dozens of loosely related classes. Namespacing services by domain keeps things navigable.

app/
services/
users/
register_user.rb
deactivate_user.rb
payments/
charge_customer.rb
refund_payment.rb
admin/
generate_report.rb
export_users_csv.rb

Each namespaced service should be wrapped in a matching module:

# app/services/payments/charge_customer.rb
module Payments
class ChargeCustomer
def self.call(...)
new(...).call
end

def initialize(customer:, amount:)
@customer = customer
@amount = amount
end

def call
# charge logic
end
end
end

Calling code then references the full namespace, which makes it immediately clear what domain the logic belongs to:

Payments::ChargeCustomer.call(customer: current_user, amount: 4999)

Recommendations for Large Rails Applications

For applications with dozens or hundreds of services, group them by business domain rather than by technical function. A users namespace for everything related to user accounts is more useful than splitting services into folders like create_services and update_services, which describe implementation details rather than what the code actually does. Keep a consistent naming convention across the codebase, typically verb-first names like RegisterUser, ChargeCustomer, or ImportProducts, so anyone scanning the directory can understand what each class does without opening the file.


Handling Errors

How a Service Object communicates failure is one of the most important design decisions you will make. There are two broad approaches: returning a result object that indicates success or failure, and raising exceptions.

Returning Success or Failure Objects

For expected failures, like validation errors or a declined payment, returning a result object is usually the better choice. Exceptions are meant for exceptional, unexpected situations, not for routine business outcomes like "this credit card was declined."

class ChargeCustomer
Result = Struct.new(:success?, :charge, :error, keyword_init: true)

def self.call(...)
new(...).call
end

def initialize(customer:, amount:)
@customer = customer
@amount = amount
end

def call
charge = PaymentGateway.charge(customer: @customer, amount: @amount)

if charge.successful?
Result.new(success?: true, charge: charge, error: nil)
else
Result.new(success?: false, charge: nil, error: charge.decline_reason)
end
rescue PaymentGateway::ConnectionError => e
Rails.logger.error("Payment gateway connection failed: #{e.message}")
Result.new(success?: false, charge: nil, error: "Payment service is temporarily unavailable.")
end
end

Raising Exceptions

Exceptions are appropriate for truly unexpected conditions, such as a programming error or a violated invariant that should never happen if the rest of the system is working correctly. Reserve them for situations the calling code is not expected to handle gracefully.

def call
raise ArgumentError, "amount must be greater than zero" if @amount <= 0
# continue processing
end

Validation Failures

When a service wraps a model save, propagate the model's own validation errors rather than inventing a separate error format. This keeps error messages consistent across the application and avoids duplicating validation logic in the service itself.

Logging

Services are a natural place to add structured logging around important business events, since they represent meaningful actions rather than incidental code paths.

Rails.logger.info("User #{user.id} registered successfully")

User-Friendly Feedback

Whatever error handling approach you choose, make sure the calling controller can translate the result into a message the end user can actually understand. A raw exception message or a technical error code should never be shown directly in the UI.


Transactions in Service Objects

Many services coordinate changes across multiple models, and those changes often need to succeed or fail together. Active Record's transaction block is the standard tool for this.

module Orders
class Create
Result = Struct.new(:success?, :order, :errors, keyword_init: true)

def self.call(...)
new(...).call
end

def initialize(user:, cart:)
@user = user
@cart = cart
end

def call
order = nil

ActiveRecord::Base.transaction do
order = @user.orders.create!(total: @cart.total)

@cart.items.each do |item|
order.line_items.create!(product: item.product, quantity: item.quantity, price: item.product.price)
end

InventoryService.decrement_stock!(order)
end

Result.new(success?: true, order: order, errors: [])
rescue ActiveRecord::RecordInvalid => e
Result.new(success?: false, order: nil, errors: [e.message])
end
end
end

If any step inside the transaction block raises an error, such as create! failing validation or InventoryService.decrement_stock! running out of stock and raising, the entire block rolls back. No order and no line items are left behind in an inconsistent state. This is one of the most valuable things a Service Object gives you: a clear, single place where a multi-model operation is defined as atomic, rather than that logic being scattered across callbacks on different models that each fire independently.


Testing Service Objects

One of the strongest arguments for Service Objects is how much easier they are to test compared to controller actions or heavily callback-driven models. Here is a set of RSpec examples for the RegisterUser service built earlier.

# spec/services/register_user_spec.rb
require "rails_helper"

RSpec.describe RegisterUser do
describe ".call" do
context "with valid attributes" do
it "creates a user" do
expect {
described_class.call(email: "[email protected]", password: "secret123", name: "Jamie")
}.to change(User, :count).by(1)
end

it "creates an associated profile" do
result = described_class.call(email: "[email protected]", password: "secret123", name: "Jamie")
expect(result.user.profile).to be_present
end

it "sends a welcome email" do
expect {
described_class.call(email: "[email protected]", password: "secret123", name: "Jamie")
}.to have_enqueued_mail(UserMailer, :welcome)
end

it "returns a successful result" do
result = described_class.call(email: "[email protected]", password: "secret123", name: "Jamie")
expect(result.success?).to be(true)
end
end

context "with invalid attributes" do
it "does not create a user" do
expect {
described_class.call(email: "", password: "secret123", name: "Jamie")
}.not_to change(User, :count)
end

it "returns a failed result with errors" do
result = described_class.call(email: "", password: "secret123", name: "Jamie")
expect(result.success?).to be(false)
expect(result.errors).to include("Email can't be blank")
end
end
end
end

Mocking External APIs

When a service calls an external API, such as a payment gateway, mock that dependency so your test suite does not make real network requests.

RSpec.describe Payments::ChargeCustomer do
it "returns a successful result when the gateway succeeds" do
allow(PaymentGateway).to receive(:charge).and_return(
instance_double(PaymentGateway::Charge, successful?: true)
)

result = described_class.call(customer: create(:user), amount: 2000)
expect(result.success?).to be(true)
end

it "returns a failed result when the gateway declines the charge" do
allow(PaymentGateway).to receive(:charge).and_return(
instance_double(PaymentGateway::Charge, successful?: false, decline_reason: "insufficient funds")
)

result = described_class.call(customer: create(:user), amount: 2000)
expect(result.success?).to be(false)
expect(result.error).to eq("insufficient funds")
end
end

Why Service Objects Are Easier to Test

Testing a controller action typically means simulating a full HTTP request, dealing with routing, session state, and rendered responses just to verify a small piece of business logic. Testing a model method that is triggered by a callback often means you cannot isolate that one behavior without also saving a valid record and letting every other callback run too. A Service Object sidesteps both problems. You instantiate it directly, call it with exactly the inputs you want to test, and assert on a clear, predictable result. There is no framework machinery standing between your test and the logic you actually care about.


Refactoring a Fat Controller

Here is a realistic "before" controller that has accumulated too much responsibility over time:

# Before
class SubscriptionsController < ApplicationController
def create
plan = Plan.find(params[:plan_id])

if current_user.subscriptions.active.exists?
flash[:alert] = "You already have an active subscription."
redirect_to plans_path and return
end

charge = PaymentGateway.charge(customer: current_user, amount: plan.price)

if charge.successful?
subscription = current_user.subscriptions.create!(
plan: plan,
status: "active",
started_at: Time.current
)
current_user.update!(subscribed: true)
SubscriptionMailer.confirmation(subscription).deliver_later
Rails.logger.info("Subscription #{subscription.id} created for user #{current_user.id}")
redirect_to dashboard_path, notice: "Subscribed successfully!"
else
flash[:alert] = "Payment failed: #{charge.decline_reason}"
redirect_to plans_path
end
end
end

This action handles payment processing, subscription creation, user updates, email delivery, and logging, all inside a single controller method. It is difficult to test without hitting the payment gateway and rendering a full response, and any reuse of this logic outside a web request, such as from an admin console, would mean duplicating the entire method.

Here is the same feature after extracting a service:

# app/services/subscriptions/create.rb
module Subscriptions
class Create
Result = Struct.new(:success?, :subscription, :error, keyword_init: true)

def self.call(...)
new(...).call
end

def initialize(user:, plan:)
@user = user
@plan = plan
end

def call
return already_subscribed_result if @user.subscriptions.active.exists?

charge = PaymentGateway.charge(customer: @user, amount: @plan.price)
return failed_charge_result(charge) unless charge.successful?

subscription = create_subscription
Result.new(success?: true, subscription: subscription, error: nil)
end

private

def create_subscription
ActiveRecord::Base.transaction do
subscription = @user.subscriptions.create!(plan: @plan, status: "active", started_at: Time.current)
@user.update!(subscribed: true)
SubscriptionMailer.confirmation(subscription).deliver_later
Rails.logger.info("Subscription #{subscription.id} created for user #{@user.id}")
subscription
end
end

def already_subscribed_result
Result.new(success?: false, subscription: nil, error: "You already have an active subscription.")
end

def failed_charge_result(charge)
Result.new(success?: false, subscription: nil, error: "Payment failed: #{charge.decline_reason}")
end
end
end
# After
class SubscriptionsController < ApplicationController
def create
plan = Plan.find(params[:plan_id])
result = Subscriptions::Create.call(user: current_user, plan: plan)

if result.success?
redirect_to dashboard_path, notice: "Subscribed successfully!"
else
flash[:alert] = result.error
redirect_to plans_path
end
end
end

The controller went from twenty-plus lines mixing several unrelated concerns down to a handful of lines focused purely on request handling. The service can now be tested directly, reused from other entry points, and understood on its own without needing to trace through routing and rendering logic.


Refactoring a Fat Model

Models accumulate business logic just as easily as controllers, often through callbacks. Here is a model that has taken on too much:

# Before
class Order < ApplicationRecord
belongs_to :user
has_many :line_items

after_create :send_confirmation_email
after_create :notify_warehouse
after_update :update_loyalty_points, if: :saved_change_to_status?

def total
line_items.sum { |item| item.price * item.quantity }
end

private

def send_confirmation_email
OrderMailer.confirmation(self).deliver_later
end

def notify_warehouse
WarehouseApi.notify_new_order(self)
end

def update_loyalty_points
return unless status == "completed"

user.increment!(:loyalty_points, (total / 10).to_i)
end
end

The problem here is that saving an Order for any reason, including in a test factory or a data migration, triggers side effects like sending real emails and calling an external warehouse API. This makes the model unpredictable to work with and hard to test in isolation.

Here is the refactored version, with the side effects extracted into a service and the callbacks removed:

# After
class Order < ApplicationRecord
belongs_to :user
has_many :line_items

def total
line_items.sum { |item| item.price * item.quantity }
end
end
# app/services/orders/complete.rb
module Orders
class Complete
def self.call(...)
new(...).call
end

def initialize(order:)
@order = order
end

def call
ActiveRecord::Base.transaction do
@order.update!(status: "completed")
award_loyalty_points
end

notify_warehouse
send_confirmation_email
@order
end

private

def award_loyalty_points
points = (@order.total / 10).to_i
@order.user.increment!(:loyalty_points, points)
end

def notify_warehouse
WarehouseApi.notify_new_order(@order)
end

def send_confirmation_email
OrderMailer.confirmation(@order).deliver_later
end
end
end

Now Order is a plain data model with a single computed attribute. Saving an order in a test no longer sends emails or calls an external API unless you explicitly call Orders::Complete. This is a significant improvement for test speed and predictability, and it makes the sequence of what happens when an order is completed explicit and easy to follow in one file, rather than scattered across three separate callbacks that only reveal themselves by reading the entire model.


Common Mistakes

Even experienced teams run into a handful of recurring problems when adopting Service Objects.

Creating a Service Object for every tiny method. Not every action needs a service. Simple, single-model CRUD operations are usually clearer left inline in the controller.

Mixing presentation logic into a service. A service should return data, not pre-formatted strings meant for a view. Keep formatting concerns in helpers or view components.

Accessing controller methods directly. A Service Object should never reach back into current_user, session, or params on its own. Pass everything it needs explicitly as arguments, so the service stays usable outside of a request context.

Ignoring dependency injection. Hardcoding a dependency, like calling PaymentGateway.charge directly instead of accepting a gateway object as an argument, makes services harder to test and harder to swap out later.

Poor naming. A service named UserService or OrderHelper tells you nothing about what it actually does. Favor verb-first names that describe the action, like RegisterUser or CancelOrder.

Large Service Objects that violate the single responsibility principle. If a service has grown to handle five unrelated concerns, it needs to be split. A service that is difficult to summarize in one sentence is usually doing too much.


Best Practices

  • Keep services focused. Each service should do one thing, and its name should make that thing obvious.
  • Give services meaningful names. Verb-first, domain-specific names read almost like documentation.
  • One responsibility per service. If you find yourself adding an "and" to the description of what a service does, consider splitting it.
  • Return predictable results. Whether you use a Result struct or raise well-defined exceptions, be consistent across your codebase.
  • Keep services framework-independent whenever practical. Avoid reaching into params, session, or current_user inside a service. Pass exactly what is needed as arguments.
  • Prefer composition over inheritance. Rather than building a deep hierarchy of base service classes, favor small, composable objects that call one another explicitly.
  • Keep business logic out of controllers. If an action does more than fetch a record and call a service, that is a signal more logic needs to move out.

These practices matter even more once your application handles sensitive operations like authentication or payments. Our guide on Rails security best practices covers authorization and input validation patterns that pair naturally with the kind of clean, explicit architecture Service Objects encourage.


Real-World Example

Let's put everything together with a realistic workflow: publishing a blog article, which involves creating the article, uploading a cover image, assigning categories, notifying subscribers, and logging the activity, all inside a single atomic operation.

# app/services/articles/publish.rb
module Articles
class Publish
Result = Struct.new(:success?, :article, :errors, keyword_init: true)

def self.call(...)
new(...).call
end

def initialize(author:, title:, body:, cover_image:, category_ids:)
@author = author
@title = title
@body = body
@cover_image = cover_image
@category_ids = category_ids
end

def call
article = nil

ActiveRecord::Base.transaction do
article = @author.articles.create!(
title: @title,
body: @body,
status: "published",
published_at: Time.current
)

attach_cover_image(article)
assign_categories(article)
log_publication(article)
end

notify_subscribers(article)
Result.new(success?: true, article: article, errors: [])
rescue ActiveRecord::RecordInvalid => e
Result.new(success?: false, article: nil, errors: [e.message])
end

private

def attach_cover_image(article)
article.cover_image.attach(@cover_image)
end

def assign_categories(article)
article.category_ids = @category_ids
end

def log_publication(article)
Rails.logger.info("Article #{article.id} published by author #{@author.id}")
end

def notify_subscribers(article)
Subscriber.find_each do |subscriber|
ArticleMailer.new_article(subscriber, article).deliver_later
end
end
end
end

Walking through this step by step: the entire database portion, creating the article, attaching the image, and assigning categories, happens inside a single transaction, so a failure at any point rolls back cleanly and never leaves a half-published article behind. Notifying subscribers happens after the transaction commits, which is intentional. You never want to send emails referencing a database record that might still get rolled back. Since this service performs several database writes in sequence, it is worth double-checking that each one is efficient rather than triggering unnecessary queries, especially the subscriber lookup. Our article on optimizing Active Record queries in Rails is a useful reference for making sure loops like find_each here stay performant as your subscriber list grows, and our roundup of common Rails performance bottlenecks covers related patterns worth watching for as services like this scale in production.

Calling this from a controller is now trivial:

class ArticlesController < ApplicationController
def create
result = Articles::Publish.call(
author: current_user,
title: params[:title],
body: params[:body],
cover_image: params[:cover_image],
category_ids: params[:category_ids]
)

if result.success?
redirect_to result.article, notice: "Article published!"
else
@errors = result.errors
render :new, status: :unprocessable_entity
end
end
end

Frequently Asked Questions

What is a Service Object?
A Service Object is a plain Ruby class that encapsulates a single piece of business logic, keeping it separate from controllers, models, and helpers.

Why not put everything in the model?
Models are responsible for data and persistence. Loading them up with unrelated business logic, especially through callbacks, makes them harder to test and harder to reason about, since saving a record can trigger side effects that have nothing to do with the record itself.

Are Service Objects required in Rails?
No. Rails does not require or ship with Service Objects. They are a convention that many teams adopt because Rails does not provide a built-in place for complex business logic to live.

Where should Service Objects live?
The most common convention is app/services, often organized into subdirectories by domain, such as app/services/users or app/services/payments.

Should Service Objects inherit from ApplicationService?
It is common, though not required, to create a lightweight base class like ApplicationService that defines the shared self.call convenience method, so individual services do not need to repeat that boilerplate.

Can Service Objects call other Service Objects?
Yes, and this is a normal and often useful pattern for composing smaller services into larger workflows. Just be careful not to create long, tangled chains that become hard to follow. If a service calls three or four others, an interactor-style pattern may organize that flow more clearly.

How large should a Service Object be?
Large enough to accomplish one clear task, and no larger. If you struggle to describe what a service does in a single sentence, it is likely handling more than one responsibility.

Should I use concerns instead?
Concerns are meant for sharing behavior across models or controllers, not for encapsulating a single business process. A concern mixed into a model still lives inside that model's context, whereas a Service Object stands independently and can be called from anywhere. Use concerns for shared, reusable behavior, and use Service Objects for orchestrating a specific action.


Conclusion

Service Objects give Rails applications something the framework does not provide out of the box: a clear, dedicated home for business logic. By moving multi-step operations out of controllers and out of model callbacks, you end up with thinner controllers, more predictable models, and business logic that is easy to read, easy to reuse, and easy to test in isolation.

None of this requires a rewrite. You do not need to introduce Service Objects everywhere at once, and you should not force them onto simple, single-model actions that do not need them. Start with the parts of your application that already feel painful, a controller action that has grown too long, or a model callback chain that is hard to follow, and extract one service at a time. Over a few weeks or months, this steady, incremental refactoring adds up to a codebase that is significantly easier to work in, without ever needing to stop feature work to do it.

💌 Don’t miss out! Join my newsletter for web development tips, tutorials, and insights delivered straight to your inbox.

Thanks for reading & Happy coding! 🚀

Follow me on:

Code. Learn. Grow.

A friendly newsletter sharing dev tips, lessons, and wins from my journey.

    Enter valid email address

    Services Tailored to Your Needs


    coding

    Web & Mobile Development

    Custom websites and mobile apps built to be fast, modern, and user-friendly. From sleek landing pages to full-scale applications, I deliver solutions that engage your audience and grow your business.

    API development

    Seamlessly connect your systems with secure, scalable APIs. I design and integrate APIs that improve efficiency, reliability, and flexibility for your business processes.

    Database design and management

    Reliable database solutions tailored to your needs. I design, optimize, and maintain databases that ensure performance, security, and scalability for your applications.

    You might also like…

    Understanding Strong Parameters in Rails
    Ruby On Rails

    Understanding Strong Parameters In Rails

    By Jean Emmanuel Cadet
    Published on: Jul 27, 2026
    How to Optimize Active Record Queries in Rails
    Ruby On Rails

    How To Optimize Active Record Queries In Rails

    By Jean Emmanuel Cadet
    Published on: Jul 24, 2026
    Ruby on Rails Security Best Practices (2026 Guide)
    Ruby On Rails

    Ruby On Rails Security Best Practices (2026 Guide)

    By Jean Emmanuel Cadet
    Published on: Jul 22, 2026
    CodeCurious

    Designed for those who view software as architecture and code as literature.

    Legal

    Terms & Conditions Privacy Policy Disclaimer

    CodeCurious © 2025 - 2026. All rights reserved. | Made with ♥ by @jecode93