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 styleThey 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
endCallers 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
endEvery 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
endCall 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.