Ruby On Rails Service Objects Explained (2026 Guide)
Learn how Ruby on Rails service objects improve code organization, testing, and maintainability with practical examples.
• 27 min read
• 27 min read
Learn how Ruby on Rails service objects improve code organization, testing, and maintainability with practical examples.
• 27 min read
• 27 min read
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.
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.
It helps to think about each part of a Rails application in terms of responsibility:
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.
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.
The benefits of Service Objects become obvious once you have worked in a codebase without them. Here are the most significant advantages.
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.
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.
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.
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.
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.
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.
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.
Service Objects shine whenever an action involves more than a single, simple database write. Some of the most common scenarios include:
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.
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.
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
# 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.
# 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.
# 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.
# config/routes.rb
Rails.application.routes.draw do
resource :registration, only: [:new, :create]
end
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.
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.
call Class Method PatternThis 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.
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.
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.
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.
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.
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)
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.
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.
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
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
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.
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")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.
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.
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
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
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.
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.
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.
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.
params, session, or current_user inside a service. Pass exactly what is needed as arguments.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.
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
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.
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.