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 recommending dry-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 a call method.

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. With Articles::PublishingQuota as a standalone domain object, you test it with a single new(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.