Ruby on Rails: Rethinking Service Objects
[#ror #tech]
(note: original article has been published on dev.to a while ago , this is an updated version!)
In the Ruby on Rails community, service objects have gained popularity as a way to encapsulate business logic and keep controllers and models lean. However, there’s a growing trend of misusing service objects, particularly those with “-er” or “-or” suffixes. This article aims to shed light on poorly implemented service objects and provide guidance on creating more effective, domain-focused alternatives.
Many developers tend to name their service objects with suffixes like -er or -or, such as ArticleCreator or UserAuthenticator. While this naming convention may seem intuitive, it often leads to a procedural style of programming rather than embracing the object-oriented nature of Ruby.
It’s becoming evident that service objects the way we often see them are not actually “objects”, but just procedures, or better to say, processes.
[UPDATED] There’s a well-known name for this pattern. Martin Fowler calls it a Transaction Script, a procedure that handles a single business request from start to finish. Naming it helps, because it reveals the real tension. Transaction Scripts aren’t bad by default, but pretending they are OOP objects is what causes long-term design confusion. There’s also a name for what happens to your models as a result, it’s called the Anemic Domain Model anti-pattern where domain classes become passive data containers because all behavior migrated into service objects.
[UPDATED] As of 2026, there’s another angle. LLMs write bad service objects by default. Tools like GitHub Copilot, Cursor, and ChatGPT have been trained on enormous amounts of open-source Rails code, the majority of which follows the ArticleCreator-style pattern. When you ask an LLM to “create a service object for X,” it will almost always produce a procedural call-method wrapper with instance variables, because that’s what the training data is saturated with. The bad pattern is now spreading through codebases everywhere, pasted in by developers who trust the output without questioning the design. Yes you can provide your custom system instructions to prevent doing that, but, well, let’s be honest, the majority people won’t if they didn’t know it’s a problem from the very beginning. If your team uses AI-assisted coding, and most do, this makes having an explicit architectural convention more important.
Let’s take a look at the real-world example of a codebase powering the dev.to community: Articles::Creator .
The class seems okay at first glance; at least the code is organized and lives in its own space. It could be workable. However, we’re just hiding the problem in the long term.
class ArticleCreator
def initialize(user, article_params)
@user = user
@article_params = article_params
end
def call
rate_limit!
create_article
subscribe_author
refresh_auto_audience_segments if @article.published?
@article
end
private
def rate_limit!
# Rate limiting logic
end
def create_article
@article = Article.create(@article_params.merge(user_id: @user.id))
end
def subscribe_author
NotificationSubscription.create(
user: @user,
notifiable: @article,
config: "all_comments"
)
end
def refresh_auto_audience_segments
@user.refresh_auto_audience_segments
end
end
A Domain-Centric Approach
Instead of focusing solely on actions (creating, updating, deleting), we should model our service objects around domain concepts and processes. Hence my suggestion: let’s stop pretending service objects are objects and start calling them processes instead.
NOTE: I’m not talking about DDD (Domain-Driven Design) since it’s a whole separate topic, however, we can be inspired by some of the concepts.
I’ll be using dry-* libraries to demonstrate how a process might look:
[UPDATED] A quick note on tooling: the original version of this article used
dry-transaction, which has since been deprecated. The examples below have been updated to use its successor,dry-operation, which offers a more flexible instance-level API. If you’ve seen older posts recommendingdry-transaction, migrate away from it. The core pattern (explicit steps, typed results) remains the same.
[UPDATED] The
Success()/Failure()return values you’ll see below aren’t arbitrary conventions. They come from Railway-Oriented Programming (ROP), computation flows along a “happy path” until a step fails, at which point it switches to a “failure track” and skips the remaining steps entirely. This makes error handling explicit and composable, rather than hidden in rescue blocks or boolean flags scattered across acallmethod.
module Articles
class PublishingProcess
include Dry::Operation
def call(user:, params:)
input = { user: user, params: params }
input = step validate(input)
input = step check_rate_limit(input)
input = step create_article(input)
input = step subscribe_author(input)
input = step refresh_audience(input)
step notify_subscribers(input)
end
private
def validate(input)
result = Articles::PublishingContract.new.call(input[:params])
result.success? ? Success(input) : Failure(result.errors)
end
def check_rate_limit(input)
quota = Articles::PublishingQuota.new(input[:user])
quota.within_limit? ? Success(input) : Failure(:rate_limited)
end
def create_article(input)
article = Articles::Repository.new.create(input[:user], input[:params])
Success(input.merge(article: article))
end
def subscribe_author(input)
Articles::AuthorSubscription.new(input[:user], input[:article]).create
Success(input)
end
def refresh_audience(input)
Articles::Audience.new(input[:user]).refresh_segments
Success(input)
end
def notify_subscribers(input)
Articles::SubscriberNotification.new(input[:article]).dispatch
Success(input)
end
end
end
See, not only is the business process very well defined, but it also deals with domain-centric objects: Articles::PublishingContract, Articles::PublishingQuota, Articles::Repository, Articles::AuthorSubscription, and so on.
Thanks to having a clear separation between Processes and Domain objects, we’ll stop pretending that we’re playing the OOP game when dealing with service objects.
[UPDATED] The practical payoff here is testability. With the original
ArticleCreator, testing rate limiting requires setting up a full user, article, and database state. WithArticles::PublishingQuotaas a standalone domain object, you test it with a singlenew(user).within_limit?call. Each domain object is a unit. Each process step is a contract. Your test suite gets faster and more meaningful.
Let’s imagine an ideal world where each app consisted of Process-like implementations. As a result, our app might look like a set of business processes. One process might start another as an async job, or some processes might be executed in the background on a scheduled basis.
# Hotel Booking Process
module Bookings
class HotelReservationProcess
include Dry::Operation
# step :check_room_availability
# step :calculate_total_cost
# step :process_deposit
# step :create_reservation
# step :send_confirmation
# step :schedule_reminders
end
end
# User Registration Process
module Users
class RegistrationProcess
include Dry::Operation
# step :validate_user_data
# step :check_unique_email
# step :create_user
# step :send_verification_email
# step :assign_default_roles
end
end
[UPDATED] If you’re not ready to adopt
dry-operation, the pattern still applies, the tooling is secondary and you can easily reproduce the pattern with simple POROs.
I think it goes without saying that having a clear separation of processes and a set of domain-centric objects powering these processes is a cleaner way to write business logic. However, I understand that the “ideal” world and the “real” world are sometimes completely different pictures. It always boils down to engineering contracts established in a particular team or the whole organization, and sometimes it’s not that easy to change habits, especially when we’re talking about legacy production applications.
Nevertheless, what we can do is refine our mental models - to perceive “business processes” rather than “some objects”, and incorporate the business language into the domain language of our system.