← All posts

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.

rails patterns architecture service-objects

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. PublishPostService for 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 #perform should 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, not ChargeCreator. PublishPost, not PostPublisher. A service is an action, and Ruby method names are verbs — the class name should match. The -er suffix is a noun phrase and it invites scope creep (“well, as the ChargeCreator, 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 Service superclass anti-pattern. Teams encountering their fifth or sixth service often decide to extract an ApplicationService base class with a .call class 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:

  1. It touches more than one aggregate — creating a user and a subscription and sending a receipt.
  2. It coordinates with external I/O the model shouldn’t know about — a payment gateway, an analytics vendor, an email provider’s HTTP API.
  3. 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 A calls B calls C calls D, you should ask whether B and C are 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::Model or attr_accessor for every form field. Move it to app/forms or app/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 it CacheFoo; if it fires off analytics, name it TrackFoo. Hidden side effects in a class called CreateOrder are how a Thursday-afternoon incident becomes a Monday-morning post-mortem.

  • The service that’s really a scope. FindActivePaidSubscribersService is Subscriber.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.