Rails

Service objects, boundaries, and tests in Rails

Service objects are useful when they make a boundary clearer. They become noise when they only move code without improving the system.

Rails Paulo Fidalgo

Service objects help when they make a boundary clearer. They become noise when they only move code out of a controller without improving the system.

The name matters less than the contract.

Service, command, operation, actor: the useful object gives the application one place for one business action, one set of inputs, and one set of tests.

What belongs behind the boundary

A service boundary earns its keep when the operation coordinates work that does not belong naturally to one model:

  • validating inputs from more than one source.
  • calling several collaborators in a specific order.
  • handling failure paths explicitly.
  • returning a result the caller can reason about.
  • keeping controllers and jobs focused on orchestration.

This is different from creating a service object for every three-line method. If the object does not clarify ownership, it probably adds indirection.

Inputs and outputs matter

The best service objects make their inputs obvious. They should not quietly reach into request state, global configuration, or half the database unless that is part of the contract.

That contract matters because it decides how the code will be tested.

A service that accepts clear inputs and returns a clear result can be tested without building the entire application around it.

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

  def initialize(article:, notifier:)
    @article = article
    @notifier = notifier
  end

  def call
    return Result.failure(:not_ready) unless article.ready_to_publish?

    article.publish!
    notifier.article_published(article)

    Result.success(article)
  end

  private

  attr_reader :article, :notifier
end

The exact Result implementation is not the issue. The caller can see what the operation needs and what it gives back.

Stubs and mocks are different tools

Testing these objects is where teams often blur concepts.

A stub controls an indirect input. It lets the test say, “for this example, this collaborator returns this value.”

A mock verifies an interaction. It lets the test say, “this collaborator must be called in this way.”

Those give different kinds of confidence.

If the collaborator’s returned value drives the branch you care about, a stub is often enough. If the operation exists partly to trigger a side effect, a mock can be appropriate.

What to test

For service objects, I care about four things:

  • valid input produces the expected result.
  • invalid input fails in a legible way.
  • important collaborators are called when the operation succeeds.
  • collaborators are not called when the operation fails early.

That is usually more useful than testing private helpers or mirroring every line of implementation.

Avoid framework-shaped thinking

Libraries such as Actor can be useful because they make inputs, outputs, and failures explicit.

They are not a substitute for judgment.

If a gem helps the team agree on contracts, result handling, and dependency injection, it can reduce friction. If it becomes ceremony around simple code, it is doing the opposite.

The value is not the service layer. The value is making business operations easier to read, change, and test.