Two patterns dominate the Ruby service-object landscape:

AccountUpdater.call(1, email: "foo@bar.com")      # class-method style
AccountUpdater.new(1, email: "foo@bar.com").call  # throwaway-instance style

They look different. They aren’t. Both pass per-invocation data at construction time, which leaves nowhere for dependencies to live. That’s the problem — not the self. keyword.

What happens when a dependency shows up

Here is a typical starting point:

class AccountUpdater
  def self.call(id, params)
    account = Account.find(id)
    account.update(params)
  end
end

Callers look like this:

AccountUpdater.call(1, email: "foo@bar.com")

Now a requirement arrives: accounts can upload a logo to a cloud provider, and the provider varies per tenant. The service needs an uploader.

With the class-method shape, the uploader has nowhere to live except in the argument list:

class AccountUpdater
  def self.call(id, params, uploader)
    account = Account.find(id)

    if params[:logo]
      uploader.upload(params[:logo], key: "account/#{id}/logo.jpg")
    end

    account.update(params.except(:logo))
  end
end

Every caller has to change:

AccountUpdater.call(1, { email: "foo@bar.com", logo: file }, uploader)

The interface leaked. Callers now know that AccountUpdater needs an uploader — something they had no business knowing before. Add a second collaborator (a notifier, a cache, an audit log) and the signature grows again, and every call site changes again.

The root cause

Service.call(args) and Service.new(args).call both treat the class as a thin wrapper around a function. They conflate two things that should live in different places:

  • Dependencies — collaborators like uploaders, HTTP clients, loggers. Stable across calls. Chosen once, at the composition root.
  • Data — the id, the params, the thing being acted on. Varies per call.

OOP already has a home for each: constructor for dependencies, method for data.

class AccountUpdater
  def initialize(uploader:)
    @uploader = uploader
  end

  def call(id, params)
    account = Account.find(id)

    if params[:logo]
      @uploader.upload(params[:logo], key: "account/#{id}/logo.jpg")
    end

    account.update(params.except(:logo))
  end
end

Call sites stay the same shape regardless of what the service needs internally:

account_updater.call(1, email: "foo@bar.com", logo: file)

Add a notifier, a cache, a feature-flag client — the constructor grows, not the call site. The caller keeps its ignorance about the service’s collaborators, which is what encapsulation is supposed to buy.

When class methods are fine

This isn’t an argument against self.. Class methods are the right tool for pure functions and alternative constructors:

Money.from_cents(500)
URI.parse("https://example.com")

The moment a class method reaches out to a collaborator — an uploader, a mailer, an HTTP client — it stops being a function and starts being an object pretending not to be one. That’s the pattern worth replacing.