Service Objects vs Interactors in Rails

When a plain PORO is enough, when the interactor gem earns its keep, and where dry-rb command objects actually fit on a Rails monolith at scale.

It was past midnight UTC at the creator economy platform I worked at, 6 p.m. Pacific, the night we shipped what was supposed to be a “safe” migration on users. I’d reviewed it that morning. strong_migrations was in the gemfile. The author had used add_column_with_default. Login error rate hit 100% for 85 seconds while Aurora chewed through the backfill under an ACCESS EXCLUSIVE lock.

What does that have to do with service objects. Bear with me.

The PR also moved a chunk of User#activate! into a new Users::ActivationService PORO. Nice cleanup. Tests passed. Coverage went up. And none of that mattered, because the actual problem lived three layers down in a callback chain that the service object had quietly preserved instead of killed. The pattern hadn’t hurt. It also hadn’t helped. That’s the part nobody writes about.

I’ve been writing Rails service objects since my early industrial-ERP days. Picked up the interactor gem at the digital product agency I led engineering at, during the portfolio DDD migration. I touch dry-monads on a Rails surface bolted onto one of the side products I CTO. They’re all useful. They’re not interchangeable. And most teams reach for the heaviest one when they didn’t need the lightest.

The default should be a PORO

If a Rails action does one or two database writes and fires a job, you do not need a gem. You need a class with one public method.

# app/services/users/activate.rb
module Users
  class Activate
    def self.call(user:, source:)
      new(user: user, source: source).call
    end

    def initialize(user:, source:)
      @user = user
      @source = source
    end

    def call
      return Result.failure(:already_active) if @user.activated_at?

      ActiveRecord::Base.transaction do
        @user.update!(activated_at: Time.current, activation_source: @source)
        WelcomeMailer.with(user: @user).welcome.deliver_later
        ActivationAuditEvent.create!(user_id: @user.id, source: @source)
      end

      Result.success(@user)
    rescue ActiveRecord::RecordInvalid => e
      Result.failure(e.record.errors.full_messages.join(', '))
    end

    Result = Struct.new(:success?, :value, :error, keyword_init: true) do
      def self.success(value); new(success?: true, value: value); end
      def self.failure(error); new(success?: false, error: error); end
    end
  end
end

Caller does result = Users::Activate.call(user:, source: 'signup') and branches on result.success?. No DSL. No callback chain. No before_* hooks to memorize. A junior engineer reads this top to bottom in 20 seconds.

Validations stay on the model. Policy stays in Pundit or a small Policy PORO. The service orchestrates a use case across more than one collaborator. If your “service” only calls user.update! and nothing else, you wrote a method, not a service. Inline it.

Where the interactor gem earns its keep

The thing POROs get wrong is rollback semantics across multiple side effects when some of them aren’t database writes. ActiveRecord transactions only roll back what AR knows about. They don’t reverse a Stripe charge, they don’t un-publish a webhook, they don’t un-call a third-party API.

That’s the gap the interactor gem fills. Organizers, contexts, and explicit rollback blocks per step.

# Gemfile
gem 'interactor', '~> 3.1'

# app/interactors/subscriptions/create_paid.rb
module Subscriptions
  class CreatePaid
    include Interactor

    def call
      sub = Subscription.create!(user: context.user, plan: context.plan, status: 'pending')
      context.subscription = sub

      charge = Stripe::Charge.create(
        amount: context.plan.amount_cents,
        currency: 'usd',
        customer: context.user.stripe_customer_id,
        idempotency_key: "sub-#{sub.id}"
      )
      context.charge_id = charge.id

      sub.update!(status: 'active', stripe_charge_id: charge.id)
    rescue Stripe::CardError => e
      context.fail!(error: e.message)
    end

    def rollback
      if context.charge_id
        Stripe::Refund.create(charge: context.charge_id, reason: 'requested_by_customer')
      end
      context.subscription&.update(status: 'reverted')
    end
  end
end

# app/interactors/subscriptions/checkout.rb
module Subscriptions
  class Checkout
    include Interactor::Organizer
    organize ValidateCoupon, CreatePaid, GrantAccess, NotifyCreator
  end
end

If NotifyCreator blows up, every prior interactor’s rollback runs in reverse order. The Stripe charge gets refunded, the subscription row gets marked reverted, and the coupon hold gets released. You cannot fake that with a single ActiveRecord::Base.transaction block, and trying to do it by hand inside a PORO is exactly how you end up with the war story I’m about to tell.

When Apple billed everyone twice

Branded-mobile-app native billing at the creator platform I worked at. Apple IAP and Google Play receipts validated server-side, subscription state mirrored in creator_subscriptions. The handler had been in production six months. Quietly running. I was on a different squad.

A creator opened a ticket: “All my customers got charged twice this month and the app shows two active subscriptions.” Pulled logs. Apple’s SubscriptionRenewal server-to-server notification had retried after our endpoint returned 200 OK slightly past its 30-second deadline. The renewal handler was a PORO. No idempotency key. Every retry created a new creator_subscriptions row. A few thousand customers across dozens of branded apps, all double-charged.

First wrong fix went out within an hour. Frontend tweak to show only the latest row per customer. Visible-only fix. Apple does not refund anything because we hid a row in our UI. The creator escalated to legal.

Real fix had two halves. Database-level unique constraint on (apple_original_transaction_id, notification_uuid). And the handler became an interactor with explicit rollback, then enqueues a Sidekiq job and returns 200 OK within five seconds so Apple’s retries hit an idempotent target. Coordinated refunds with Apple’s developer API took four days because their volume cap requires per-transaction approval.

Lesson. Server-to-server notifications from Apple, Google, Stripe, anyone with a 30-second timeout and aggressive retries, do not belong in a PORO that pretends ActiveRecord transactions cover it. Idempotency keys are not optional. They’re the contract.

Where dry-rb command objects fit

I like dry-monads. I do not deploy it to teams who haven’t asked for it.

# Gemfile
gem 'dry-monads', '~> 1.6'

# app/commands/payouts/initiate.rb
class Payouts::Initiate
  include Dry::Monads[:result, :do]

  def call(creator_id:, amount_cents:)
    creator = yield find_creator(creator_id)
    yield ensure_payout_eligible(creator)
    payout = yield create_payout_row(creator, amount_cents)
    transfer = yield create_stripe_transfer(payout)
    yield mark_paid(payout, transfer.id)
    Success(payout.reload)
  end

  private

  def find_creator(id)
    creator = Creator.find_by(id: id)
    creator ? Success(creator) : Failure(:creator_not_found)
  end

  def ensure_payout_eligible(creator)
    creator.payouts_enabled? ? Success(creator) : Failure(:payouts_disabled)
  end

  def create_payout_row(creator, cents)
    Try { Payout.create!(creator: creator, amount_cents: cents, status: 'pending') }
      .to_result
      .or { |e| Failure([:db, e.message]) }
  end
end

yield short-circuits on first Failure. Each step’s failure is a clean tagged value the caller pattern-matches on with case ... in Success(p) then .... Beautiful code in the abstract. Also a tax. Engineers from Rails idioms have to learn a different mental model: results instead of exceptions, monads instead of early returns. I’ve watched that tax produce real onboarding friction at more than one company.

Worth it for teams already running NestJS or another typed-results backend in parallel, or teams maintaining a library other Rails apps consume. For a single Rails monolith with a small team, usually more weight than it removes.

The over-engineering trap

The trap looks like this. Someone reads a service-object article. Refactors UsersController#create into Users::Create.call. Feels great. Two months later half the controllers are service-object passthroughs. Someone discovers interactor. Half of those become organizers. Six months later half of those become dry-monads pipelines. The codebase has three conventions, none documented, all defended.

A test for whether the abstraction earns its keep. Open the service / interactor / command. If the public method is shorter than ten lines and only one is a real verb (the rest is parameter shuffling), the abstraction is taxing you. Inline it.

# What you should not write
class Posts::Publish
  def self.call(post:)
    new(post: post).call
  end

  def initialize(post:); @post = post; end

  def call
    @post.update!(published_at: Time.current)
  end
end

# What this should have been
post.update!(published_at: Time.current)

If you genuinely need an audit event and a job and a cache bust on publish, then yes, extract it. Until then, the controller calling post.update! is fine. Rails’ “fat models, skinny controllers” never said “every action gets its own class”.

How I actually pick on a real PR

Three questions, in order:

  1. Does this use case touch a non-database side effect that needs to roll back when the next step fails. Stripe, Apple IAP, an outbound webhook, a Slack notification you can’t no-op. If yes, interactor. If no, keep going.
  2. Does this use case have more than two failure modes that the caller needs to branch on. If yes, you want explicit result types. PORO with a Result struct usually. dry-monads only if the team has already opted in.
  3. Otherwise, a method on the model or a thin PORO. Sometimes the controller is the right home.

That’s it. No second framework. No DSL for its own sake.

Takeaways

  • Default to a PORO with a tiny Result struct. Most “service” needs are a method that grew up.
  • Reach for the interactor gem when rollback has to cover non-database side effects. Apple, Stripe, Slack, outbound webhooks.
  • dry-monads is great for teams that have already opted into typed results. Do not adopt it because it looks elegant in a blog post.
  • Idempotency keys belong at the database level, not in the service layer. Unique constraints are your contract.
  • “Fat models, skinny controllers” never required every action to have a service class. Inline what’s tiny.
  • Three different patterns in one codebase is worse than one mediocre pattern used consistently.

Thanks for reading. If you’ve got thoughts, send them my way.

© 2026 Akin Gundogdu. All Rights Reserved.