Rails architecture

Service objects and Rails conventions

A pragmatic review of when service objects clarify Rails code, when they hide domain behavior, and how to decompose them back into conventional objects.

Why service objects appear

Service objects usually appear when Rails code no longer fits comfortably in a controller, model, job, or mailer. That can be a good signal. It can also be a sign that the application has lost track of where domain behavior belongs.

The phrase "service object" is broad enough to hide many different shapes: transaction scripts, command objects, workflow coordinators, integration clients, policy-like operations, and procedural buckets with a call method.

The useful question is not whether service objects are good or bad. It is what responsibility the object owns.

Where they help

A service object helps when it names an application operation that crosses several models or external systems. Creating an account, charging a card, importing a CSV, or publishing a record can be clearer as one command with an explicit result.

It can also make transaction boundaries visible. If several records must change together, putting the operation in one place is easier to review than spreading the transaction across callbacks and controllers.

The service should still be small enough to explain. If it becomes a second application layer with its own private framework, the cure has become another disease.

Where they hide domain behavior

Service objects become harmful when they drain behavior out of domain objects. If a model has validations, invariants, and state transitions, but every meaningful change happens in services, the model becomes a data bag with callbacks.

They also hide coupling when named too generically. UserService, ContentService, and Manager objects often collect unrelated operations because the name does not resist growth.

A good service name includes the verb and the business event: PublishArticle, RegisterAccount, ImportSubscriptions. The name should make unrelated methods look out of place.

Decomposing back into Rails

When a service grows, first look for conventional Rails homes. Validations may belong on the model. Lifecycle side effects may belong in jobs or explicit event records. Authorization belongs in policy code. External HTTP calls may belong in a client object.

Callbacks deserve special care. Moving everything out of callbacks into services is not automatically better if the service now has to remember every invariant manually. Some invariants belong close to the data they protect.

The goal is not purity. The goal is for each rule to live where future changes are least likely to miss it.

A practical standard

A service object should have a narrow name, a small public interface, explicit inputs, an explicit result, and a visible transaction boundary when one exists. It should not depend on controller state or mutate global context casually.

Tests should cover the operation at the level users care about, not only that .call invokes private steps. If the object coordinates models, test the state change and failure behavior.

Rails conventions remain valuable because they make code predictable. Service objects are most useful when they extend that predictability, not when they replace it with a private architecture.

Reviewing a service object

A useful review starts by asking whether the object represents one operation or a miscellaneous place for business logic. The interface should make that answer obvious.

For example, this shape is reviewable because the command has a narrow name, explicit inputs, and a result object that callers can inspect without parsing exceptions from every internal step:

PublishArticle.call(article: article, actor: current_user).then do |result|
  if result.ok?
    redirect_to result.article
  else
    render :edit, status: :unprocessable_entity
  end
end

The service still should not own every rule. It can coordinate a transaction, authorization check, notification job, and model transition, but the invariant that makes an article publishable belongs close to the article itself.

class Article < ApplicationRecord
  def publishable?
    title.present? && body.present? && author.active?
  end
end

class PublishArticle
  def self.call(article:, actor:)
    new(article, actor).call
  end

  def initialize(article, actor)
    @article = article
    @actor = actor
  end

  def call
    return Result.failure(:not_allowed) unless Policy.allow?(@actor, :publish, @article)
    return Result.failure(:not_ready) unless @article.publishable?

    Article.transaction do
      @article.update!(published_at: Time.current)
      PublishNotificationJob.perform_later(@article.id)
    end

    Result.success(article: @article)
  end
end

This keeps the service at the application-operation level. The model still explains its own state, the job owns asynchronous delivery, and the controller only translates the result into an HTTP response.

Where the object should shrink

A service object should shrink when one of its private steps becomes a stable concept. Payment gateway calls can move to a client. Authorization can move to policy code. Formatting can move to presenters or serializers. State checks can move back onto the model they protect.

That shrinking is healthy. The service remains the application operation, while the surrounding objects own reusable concepts. If the service keeps accumulating helpers because extracting them feels inconvenient, it is becoming the same large object it was created to avoid.

A small service is easiest to delete later. That is a good property. Application operations change as the domain becomes clearer, and code that can move back into conventional Rails objects without drama has done its job.

Sources

  1. Rails Guides, Active Record Validations. https://guides.rubyonrails.org/active_record_validations.html
  2. Rails Guides, Active Record Callbacks. https://guides.rubyonrails.org/active_record_callbacks.html
  3. Rails Guides, Active Job Basics. https://guides.rubyonrails.org/active_job_basics.html