7 Ruby on Rails Service Object Patterns That Keep Your Code Clean and Testable
Learn 7 proven Ruby on Rails service object patterns — from command and orchestrator to observer — that keep your business logic clean, testable, and easy to maintain.
I remember the first time I opened a controller file in a Rails app and saw two hundred lines of if-else statements mixed with database queries, email sending logic, and permission checks. It was not readable. It was not testable. It was a mess. That day I realized that models and controllers are not the only places to put code. There is a better way.
Service objects changed how I write Ruby on Rails applications. They take business logic that does not belong in models or controllers and give it a home. Over the years I have found seven patterns that help me keep service objects small, predictable, and easy to work with. I want to share them with you, the way I wish someone had explained them to me – slowly, with code, and without fancy words.
The Command Pattern with Result Objects
The first pattern I use in almost every service is the command pattern combined with a result object. The idea is simple. A service does one thing. It either works or it does not. And when it finishes, it tells you what happened in a consistent way.
Before I used this pattern, I would write services that returned true or false. Or I would raise exceptions and catch them in the controller. Both approaches made my code unpredictable. I never knew if a service succeeded or failed just by looking at its return value.
So I created a reusable ApplicationService class. Every service inherits from it. The call method is where the magic happens. Here is the base structure.
class ApplicationService
def self.call(*args, **kwargs, &block)
new(*args, **kwargs, &block).call
end
def call
raise NotImplementedError
end
end
I also defined a Result object. It holds a success flag, an optional data payload, and an optional error message.
class Result
attr_reader :data, :error
def initialize(success:, data: nil, error: nil)
@success = success
@data = data
@error = error
end
def success?
@success
end
def failure?
!@success
end
end
Now every service returns a Result instance. The caller never has to guess what happened. Look at this real example.
class CreateOrderService < ApplicationService
def initialize(user:, order_params:)
@user = user
@order_params = order_params
end
def call
order = Order.new(@order_params.merge(user: @user))
if order.save
Result.new(success: true, data: order)
else
Result.new(success: false, error: order.errors.full_messages)
end
end
end
Using it is straightforward.
result = CreateOrderService.call(user: current_user, order_params: params)
if result.success?
render json: result.data, status: :created
else
render json: { errors: result.error }, status: :unprocessable_entity
end
I love this pattern because it forces me to think about both success and failure paths. It also makes testing easier. I can stub a service and return any result I want.
The Orchestrator Pattern for Multi‑Step Workflows
Some operations are not a single step. They are a sequence of steps, and each step can fail. When that happens, I need to undo everything that has been done so far. That is where the orchestrator pattern comes in.
I remember building a checkout flow. The steps were validate the order, reserve inventory, process payment, send confirmation email. If the payment failed, I had to release the reserved inventory. If the inventory reservation failed, I had to do nothing – but I still needed to report the failure cleanly.
The orchestrator pattern solves this by executing steps in order and keeping a list of completed steps. If any step raises a StepFailed error, the orchestrator calls a rollback method for each completed step in reverse order.
Here is how I implemented it.
class CheckoutService < ApplicationService
def initialize(order:, payment_gateway:, inventory_service:)
@order = order
@payment_gateway = payment_gateway
@inventory_service = inventory_service
@completed_steps = []
end
def call
step(:validate_order) { validate_order }
step(:reserve_inventory) { reserve_inventory }
step(:process_payment) { process_payment }
step(:send_confirmation) { send_confirmation }
Result.new(success: true, data: @order)
rescue StepFailed => e
rollback
Result.new(success: false, error: e.message)
end
private
def step(name)
result = yield
@completed_steps << name
result
end
def rollback
@completed_steps.reverse_each do |step_name|
send("rollback_#{step_name}") rescue nil
end
end
def rollback_reserve_inventory
@inventory_service.release_items(@order.line_items)
end
def rollback_process_payment
@payment_gateway.refund(@order.payment_id)
end
end
Each step method simply returns the result of a sub‑operation or raises StepFailed with a message if something goes wrong. The rollback methods are defined only for steps that changed state. If validate_order only reads data, I do not need a rollback_validate_order.
This pattern saved me from writing complex transaction blocks that mixed database rollbacks with external API compensations. The orchestrator is explicit about what it does and how it cleans up.
The Policy Object Pattern for Pre‑Execution Checks
Services often need to check if the action is allowed before doing the real work. I used to put those checks directly inside the service. The problem was that the service grew fat with conditionals. Also, testing those conditions required setting up the whole service.
I extracted those checks into a policy object. A policy object answers one question: can this action be performed? It also provides a list of reasons when the answer is no.
Consider cancelling an order. The business rules are: the order must belong to the current user, it must be in a pending state, and it must be within one hour of creation. Here is the policy.
class OrderCancellationPolicy
def initialize(order, user)
@order = order
@user = user
end
def can_cancel?
order_belongs_to_user? && order_is_pending? && within_cancellation_window?
end
def reasons
[].tap do |errors|
errors << 'Order does not belong to you' unless order_belongs_to_user?
errors << 'Order cannot be cancelled in its current state' unless order_is_pending?
errors << 'Cancellation window has expired' unless within_cancellation_window?
end
end
private
def order_belongs_to_user?
@order.user_id == @user.id
end
def order_is_pending?
@order.pending?
end
def within_cancellation_window?
@order.created_at > 1.hour.ago
end
end
Now the service uses the policy at the beginning of call.
class CancelOrderService < ApplicationService
def initialize(order:, user:)
@order = order
@user = user
end
def call
policy = OrderCancellationPolicy.new(@order, @user)
unless policy.can_cancel?
return Result.new(success: false, error: policy.reasons)
end
@order.update!(status: :cancelled, cancelled_at: Time.current)
Result.new(success: true, data: @order)
end
end
The policy object is testable by itself. I can write unit tests for can_cancel? and reasons without touching the service. The service only tests its own logic, which is now minimal. This separation keeps both files small and focused.
The Dependency Injection Pattern for Testability
I used to hardcode external dependencies like the PaymentGateway or Rails.logger inside services. That made tests slow and brittle. Every test would make a real HTTP request to the payment gateway, which was unacceptable.
Dependency injection solves this by passing dependencies as arguments to the initializer. I set sensible defaults for production, and in tests I replace them with mocks or stubs.
Here is the same payment service with injected dependencies.
class ProcessPaymentService < ApplicationService
def initialize(order:, gateway: PaymentGateway.new, logger: Rails.logger)
@order = order
@gateway = gateway
@logger = logger
end
def call
@logger.info("Processing payment for order #{@order.id}")
response = @gateway.charge(
amount: @order.total,
token: @order.payment_token
)
if response.success?
@order.update!(payment_status: :paid, paid_at: Time.current)
Result.new(success: true, data: response)
else
Result.new(success: false, error: response.error_message)
end
end
end
In the test I inject a fake gateway and an in‑memory logger.
let(:fake_gateway) { instance_double('PaymentGateway', charge: double(success?: true)) }
let(:logger) { Logger.new(StringIO.new) }
subject { described_class.new(order: order, gateway: fake_gateway, logger: logger) }
The test runs in milliseconds. No network calls. No side effects. I can verify exactly what call does by checking the result and the order’s updated attributes. Dependency injection made my test suite fast and reliable.
The Query Object Pattern for Complex Data Retrieval
Controllers and views often contain queries that chain multiple scopes and conditions. When I see that, I extract the query into a query object. A query object is a plain Ruby class that returns a collection. It does not modify state. It only fetches data.
I use a base query class that takes an ActiveRecord relation as the starting point.
class OrdersQuery
def initialize(relation = Order.all)
@relation = relation
end
def recent(limit: 10)
@relation.order(created_at: :desc).limit(limit)
end
def by_user(user)
@relation.where(user_id: user.id)
end
def pending
@relation.where(status: :pending)
end
def overdue(days: 30)
@relation.where('created_at < ? AND status = ?', days.days.ago, :pending)
end
def with_items
@relation.includes(:line_items)
end
def search(term)
@relation.where('order_number ILIKE :term OR customer_email ILIKE :term', term: "%#{term}%")
end
end
Query objects compose beautifully. I can chain methods because each returns a relation.
query = OrdersQuery.new(current_user.orders)
@orders = query.recent(limit: 20).pending
The controller stays skinny. The query object is easy to test. I can pass any relation – a scope from a user, an unscoped model, or a test double. I have used this pattern to replace long chains of where and includes that used to live in models.
The Factory Pattern for Complex Object Creation
Some objects need a lot of setup. A single creation involves multiple associations, default values, and conditional logic. Putting that logic in the controller makes it fat. Putting it in the model violates single responsibility. That is why I use a factory service object.
When I create an order, I need to build a shipping address, create line items from a list of products, apply an optional coupon, generate an order number, and calculate totals. The factory service handles all of that.
class OrderFactoryService < ApplicationService
def initialize(user:, items:, shipping_address:, coupon_code: nil)
@user = user
@items = items
@shipping_address = shipping_address
@coupon_code = coupon_code
end
def call
order = Order.new(user: @user, status: :draft)
order.shipping_address = build_address(@shipping_address)
order.line_items = build_line_items
order.coupon = apply_coupon if @coupon_code
order.calculate_totals
order.generate_order_number
Result.new(success: order.save, data: order, error: order.errors)
end
private
def build_line_items
@items.map do |item|
product = Product.find(item[:product_id])
OrderLineItem.new(
product: product,
quantity: item[:quantity],
unit_price: product.price,
currency: product.currency
)
end
end
def build_address(address_params)
Address.new(address_params.merge(user: @user))
end
def apply_coupon
coupon = Coupon.find_by(code: @coupon_code)
return unless coupon&.valid_for?(@user)
coupon
end
end
The factory ensures that every order is built consistently. If I change how line items are constructed, I only change one place. Controllers never need to know about currency conversions or address building. This pattern has saved me from duplicate code spread across multiple endpoints.
The Observer Pattern for Side Effects
Services that do one thing should not also send emails, update caches, and push notifications. Those are side effects. I take them out of the service and put them into an observer object.
The observer is a separate class that the service calls after the main action succeeds. Here is an observer for order placement.
class OrderPlacementObserver
def after_save(order)
OrderConfirmationMailer.confirm(order).deliver_later
CacheInvalidator.new.invalidate_order_cache(order)
AnalyticsTracker.track_event('order_placed', order_id: order.id)
end
end
The service accepts an observer as an injected dependency.
class PlaceOrderService < ApplicationService
def initialize(order:, observer: OrderPlacementObserver.new)
@order = order
@observer = observer
end
def call
@order.update!(status: :confirmed, confirmed_at: Time.current)
@observer.after_save(@order)
Result.new(success: true, data: @order)
end
end
In tests, I replace the observer with a no‑op object or a spy.
class NullObserver
def after_save(order); end
end
# In test
let(:observer_spy) { spy('Observer') }
subject { described_class.new(order: order, observer: observer_spy) }
it 'calls the observer' do
subject.call
expect(observer_spy).to have_received(:after_save).with(order)
end
The observer pattern keeps the service pure. The service only handles the core business logic. The observer handles everything that should happen as a consequence. This separation makes both classes easier to test and change independently.
Bringing It All Together
These seven patterns are not rules. They are tools. I pick the one that fits the problem. For a simple action with one outcome, I use the command pattern with a result object. When I have multiple steps that need rollback, I use the orchestrator. When permissions get complicated, I reach for a policy object. I inject dependencies to keep tests fast. I use query objects to keep controllers clean. Factory services handle complex creation. And observers manage side effects.
My personal rule is this: a service object should be small enough that I can read it in one sitting. If it grows beyond that, I look for a pattern to split it. The code you write today will be read by someone tomorrow – maybe even yourself six months from now. Make it simple. Make it predictable. Make it boring.
These patterns have helped me do exactly that. They turned my chaotic controllers into clean orchestration layers and made my test suite a joy to run. I hope they help you too.