May 4, 2026
Service objects in production: when they earn their file, and when they don't
An opinionated take on service objects, with code from Gumroad and Forem. The bar I apply: a service has to do something a model method can't.
Open any mid-sized Rails codebase from the last five years and count the files in app/services. On the teams I’ve consulted for, the number is rarely under forty and often over two hundred. Then open them. Half are coordinating a real workflow across two or three aggregates. The other half are a single method pulled out of a model, wrapped in a class, given a #call, and left there to collect dust and ceremony.
This post is my attempt to separate the two. I’ll show you the shape a service object earns — with code from Gumroad’s production codebase — then I’ll show you the cases where reaching for one makes the code worse, and I’ll give you a rule you can apply at the keyboard without thinking about it.
What a service object actually is
A service object is a single-purpose Ruby class that coordinates a workflow. It has one public entry point (I’ll argue for #call), a keyword-argument constructor, and no state the caller needs to poke at after invocation. It talks to models, it orchestrates writes, it returns a result. That’s the whole shape.
Three things a service object is not, because the conflation is where most of the trouble starts:
- Not a model method. If the logic operates on one aggregate and only that aggregate, it lives on the model.
post.publish!is a model method.PublishPostServicefor the same logic is ceremony. - Not a controller. Controllers translate HTTP into domain calls and back. The moment a controller has more than a dozen lines of business logic, that logic wants a service — but the service is not the controller.
- Not a background job. A job is a transport: it says “run this later, on a worker.” The job’s
#performshould do almost nothing except instantiate a service (or model method) and call it. I’ve seen teams put the whole workflow in the job class and then discover they can’t run it synchronously in a controller action. Don’t.
Tip. Name services after the verb, not the noun.
CreateCharge, notChargeCreator.PublishPost, notPostPublisher. A service is an action, and Ruby method names are verbs — the class name should match. The-ersuffix is a noun phrase and it invites scope creep (“well, as theChargeCreator, surely I should also refund charges…”).
The textbook shape
Here’s what a service in good standing looks like. It’s from Gumroad, and it searches for products that can be added to a bundle:
# antiwork/gumroad · app/services/bundle_search_products_service.rb · @8f6f1c6
class BundleSearchProductsService
PER_PAGE = 10
def initialize(bundle:, seller:, query: nil, page: 1, all: false)
@bundle, @seller, @query = bundle, seller, query
@page = [page.to_i, 1].max
@all = all
end
def call
product_response = Link.search(Link.search_options(build_search_params))
products = product_response.records.map { BundlePresenter.bundle_product(product: _1) }
total_count = product_response.results.total
{ products:, has_more: !all && (page * PER_PAGE) < total_count, page:, total_count: }
end
private
def build_search_params = # ...
end
Count what this class does: collect inputs, build a query, ship it to Elasticsearch, reshape the response. The caller writes BundleSearchProductsService.new(bundle:, seller:, query:, page:).call and gets back a hash with products and paging info. There’s no attr_writer to mutate after construction. There’s no second public method. Inputs go in the constructor; one method does the work; the result comes back.
Notice the return value is a plain hash, not a class. I consider that a feature for services at this complexity level. The moment the controller needs to distinguish success from failure, or the caller needs to chain, the hash stops carrying its weight — and that’s when I reach for a Result object.
Avoid. The
Servicesuperclass anti-pattern. Teams encountering their fifth or sixth service often decide to extract anApplicationServicebase class with a.callclass method that instantiates and dispatches, plus a declarative DSL for arguments, plus a hook for wrapping everything in a transaction. I’ve built that base class twice. Both times I regretted it. What you save is one line per subclass. What you pay is that every service now has to fit the base class’s assumptions — and some won’t. Ruby already has a convention for “class with one entry point”: it’s called a class with one public method. Don’t abstract over it.
The Result object pattern
A service returning a hash is fine until the caller needs to branch on outcome. Once you see controllers writing if result[:errors].any?, you’re ready for a Result.
Forem’s ReactionHandler wraps its return value in a small class:
# forem/forem · app/services/reaction_handler.rb · @9eb974c (AGPL-3.0, 15-line excerpt)
class Result
attr_accessor :action, :category, :reaction
delegate :errors_as_sentence, to: :reaction
def initialize(reaction: nil, action: nil, category: nil)
@reaction = reaction
@category = category
@action = action
end
def success?
reaction.errors.none?
end
end
Three attributes, a #success? predicate derived from the underlying record’s validation errors, and a delegated #errors_as_sentence the controller can use in a flash. That’s the whole thing — fifteen lines. The controller writes result = ReactionHandler.new(...).call; if result.success? ... and never has to care whether the service raised, returned nil, or returned a hash with a magic key.
This is the shape I default to when a hash isn’t enough. A nested Result class inside the service, with the attributes the caller actually needs, and a #success? predicate. No gem, no monad, no ceremony. If the service has two failure modes, I add #error and a #failure_reason symbol. If it has ten, the service is doing too much and the answer is to split it, not to grow the Result.
When a service is a method with extra steps
Here’s the shape I see most often in code reviews, and it’s wrong:
# illustrative
class ActivatePostService
def initialize(post:)
@post = post
end
def call
@post.update(active: true)
end
end
That’s a method. Putting it in app/services doesn’t make it a workflow; it makes it a one-line method surrounded by ceremony. The caller went from post.update(active: true) — or better, post.activate! — to ActivatePostService.new(post:).call. Nothing was gained. A file was added, a class was added, a test file was added, and a reader now has to jump from the controller to the service to confirm that yes, it really does call update.
My rule: a service object has to do something a model method can’t. The bar I apply is one of these three:
- It touches more than one aggregate — creating a user and a subscription and sending a receipt.
- It coordinates with external I/O the model shouldn’t know about — a payment gateway, an analytics vendor, an email provider’s HTTP API.
- It wraps multiple writes in a transaction that crosses aggregate boundaries.
One aggregate, one write, no external call? Model method. Every time. post.publish! reads better than PublishPostService.new(post:).call, the test is simpler, and the next person to touch the code doesn’t need to remember which half of the app puts its workflows in app/services.
Composing services
The interesting services, the ones that earn the file, coordinate. Here’s SaveInstallmentService#process from Gumroad, with two collaborators inside a transaction:
# antiwork/gumroad · app/services/save_installment_service.rb · @8f6f1c6
def process
set_product_and_enforce_ownership
return false if error.present?
ensure_seller_is_eligible_to_publish_or_schedule_emails
return false if error.present?
build_installment_if_needed
ActiveRecord::Base.transaction do
installment.message = SaveContentUpsellsService.new(
seller:, content: installment.message, old_content: installment.message_was
).from_html
save_installment # calls SaveFilesService.perform inside
# ... publish / schedule / preview branches
raise ActiveRecord::Rollback if error.present?
end
rescue Installment::InstallmentInvalid, Installment::PreviewEmailError => e
@error = e.message
end
A few things to take from this. First, the composition pattern: SaveContentUpsellsService rewrites the message body, SaveFilesService.perform attaches files. Both are invoked inside a single ActiveRecord::Base.transaction. That’s the right place for the transaction — it sits in the coordinator, not in the collaborators, because only the coordinator knows the boundary of the logical write.
Second, the error propagation. Gumroad uses an instance-variable @error that inner methods populate; the outer process reads error.present? after each step and raises ActiveRecord::Rollback to undo the transaction. This is pragmatic, not beautiful. If I were starting this service today I’d probably use a Result object. But the pattern — one coordinator owns the transaction, inner steps signal failure, the coordinator decides whether to roll back — is the invariant. How you carry the error is a detail.
Third, and this is the one that bites teams: the service has three early returns before it even opens the transaction. Validation happens before the transaction starts. You do not want to be inside ActiveRecord::Base.transaction doing ownership checks; you want the check to have failed cleanly, returned false, and never touched the database. Put all the “can I do this?” logic above the begin, and put the writes below it.
Smell. Services that call services that call services form a call graph you can’t see from any one file. Three levels deep is a code smell I act on immediately. If
AcallsBcallsCcallsD, you should ask whetherBandCare load-bearing or whether they’re middlemen that a reader has to walk through to understand what the workflow actually does. Usually at least one of them is the second kind, and the fix is inlining.
A complete example: transactions and idempotency
Here’s a smaller, complete service that shows the transactional pattern in isolation. It generates a default “abandoned cart” email workflow for a Gumroad seller:
# antiwork/gumroad · app/services/default_abandoned_cart_workflow_generator_service.rb · @8f6f1c6
def generate
return if seller.workflows.abandoned_cart_type.exists?
ActiveRecord::Base.transaction do
workflow = seller.workflows.abandoned_cart_type.create!(name: "Abandoned cart")
installment = workflow.installments.create!(
name: "You left something in your cart",
# ... message / type / json_data / seller_id / send_emails
)
installment.create_installment_rule!(
time_period: InstallmentRule::HOUR,
delayed_delivery_time: InstallmentRule::ABANDONED_CART_DELAYED_DELIVERY_TIME_IN_SECONDS,
)
workflow.publish!
end
end
Two things this snippet does that I want every coordinator service to do. First, the idempotency guard on line one of #generate: if the seller already has an abandoned cart workflow, return. Calling this service twice has the same effect as calling it once. That’s the property you want from anything a background job might retry, a user might double-click, or a rake task might re-run after a partial failure.
Second, the transaction boundary wraps exactly the writes that have to succeed or fail together. Three related records get created, then the workflow is published. Either all four happen or none of them do. Nothing outside the transaction depends on intermediate state.
The one thing I’d change if I owned this code: the public method is called #generate, not #call. I prefer #call for services — it lets callers compose with .method(:call), it matches the Proc/Method protocol, and it kills one bikeshed per service. Gumroad’s codebase is inconsistent (#call, #process, #generate, #perform all appear), and that inconsistency has a cost when reading code. Pick #call, enforce it with Rubocop if you have to.
Testing service objects
The spec for the service above is the test shape I recommend for coordinators:
# antiwork/gumroad · spec/services/default_abandoned_cart_workflow_generator_service_spec.rb · @8f6f1c6
describe DefaultAbandonedCartWorkflowGeneratorService do
let(:seller) { create(:user) }
subject { described_class.new(seller:) }
describe "#generate" do
context "when seller does not have an abandoned cart workflow" do
it "creates a new abandoned cart workflow and publishes it" do
expect { subject.generate }
.to change { seller.workflows.abandoned_cart_type.published.count }.from(0).to(1)
workflow = seller.workflows.abandoned_cart_type.published.sole
installment = workflow.installments.alive.sole
expect(installment.name).to eq("You left something in your cart")
expect(installment.installment_rule.time_period).to eq("hour")
end
end
# second context: idempotency — no counts change on a second call
end
end
Two contexts, two assertions about state: one for the happy path, one for idempotency. No mocks. The test hits the database, runs the real transaction, and verifies the records that came out the other side. This is what I want a service spec to look like.
The debate I hear most often on teams: should service specs be unit tests (mock the collaborators) or integration tests (hit the database and let Active Record do its thing)? My answer is almost always integration. Service objects exist because they coordinate; the thing you want to test is the coordination. If you mock every collaborator, you’ve tested that the service calls the methods you told it to call — which is the weakest form of test there is, because the test will still pass when the collaborator’s contract changes and breaks the real workflow.
Mock at the boundary of the process. Stub the HTTP call to Stripe, not the internal ChargeProcessor that wraps it. Stub the email delivery, not the NotifyUser service. Everything on your side of the network boundary is fair game for the test to exercise.
The 37signals counter-argument
Nothing in this post is uncontested. Vanilla Rails is Plenty from the 37signals dev blog is the clearest statement of the opposing view: service objects are largely ceremony, the Active Record model is where business logic belongs, and reaching for a new layer is more often avoidance of the model than improvement of the architecture. HEY and Basecamp are built on that philosophy.
I agree with a lot of it. The third of your app/services directory that’s one-line wrappers around a model update absolutely should not exist; those are model methods someone was too shy to put on the model. But I part ways with the strict version of the argument at two places.
The first is multi-aggregate workflows. When creating a user involves writing to users, subscriptions, billing_accounts, and firing off a welcome email via an HTTP API, I don’t want that method on User. User is a noun. The workflow is a verb. Putting the workflow on the noun makes User heavier every time a new cross-cutting concern is added, and over years that’s how models end up at two thousand lines.
The second is external I/O orchestration. A workflow that has to call Stripe, then write to the database, then enqueue a webhook processor, then log to an analytics vendor is not a model method no matter how hard you squint. It’s a coordination layer, and giving it a name and a home makes it possible to test, retry, and reason about.
So: 37signals are right that most service objects shouldn’t exist. They’re wrong, in my experience, that no service objects should exist. The honest position is that the bar is high and most codebases don’t meet it most of the time.
Anti-patterns to watch for
A few shapes I see in code review that I push back on every time:
-
The form object wearing a service hat. If the class’s job is to collect HTML form input, validate it, and set attributes on a model, it’s a form object, not a service. The tell is that it has
ActiveModel::Modelorattr_accessorfor every form field. Move it toapp/formsorapp/models. -
The forty-dependency service. I’ve seen constructors with twelve keyword arguments. Each one was injected “for testability.” The service ended up less testable, because the test had to construct twelve collaborators. If a service needs more than five inputs, it’s coordinating too much; split it by responsibility, not by “things it needs to know.”
-
The global-state mutator. Services that write to
Rails.cache,Rails.logger,$redis, or worse, a module-level class variable, without that write being the declared purpose of the service. The side effect is invisible to the caller and invisible to the test. If the service writes to cache, name itCacheFoo; if it fires off analytics, name itTrackFoo. Hidden side effects in a class calledCreateOrderare how a Thursday-afternoon incident becomes a Monday-morning post-mortem. -
The service that’s really a scope.
FindActivePaidSubscribersServiceisSubscriber.active.paid. The answer is almost never a service.
The takeaway
Extract a service object when a workflow crosses an aggregate boundary, touches an external system, or wraps writes in a transaction whose scope no single model owns — and not one keystroke sooner.
Code excerpts: bundle_search_products_service.rb, save_installment_service.rb, default_abandoned_cart_workflow_generator_service.rb, default_abandoned_cart_workflow_generator_service_spec.rb — Gumroad MIT, pinned at SHA 8f6f1c6007fe3ab1f1aa9f3b2f643bd32c0ee1da. reaction_handler.rb — Forem AGPL-3.0, pinned at SHA 9eb974c4d30df25b6bd7fb7854431758f61826f4, used as a 15-line excerpt under fair use.