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.
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.
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.
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.
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 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”.
Three questions, in order:
Result struct usually. dry-monads only if the team has already opted in.That’s it. No second framework. No DSL for its own sake.
Result struct. Most “service” needs are a method that grew up.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.Thanks for reading. If you’ve got thoughts, send them my way.