Solid Service Objects with Interactor
Written: Jun 20, 2025
Most non-trivial Rails applications eventually outgrow the basic MVC pattern. You start with simple controller actions and model methods, but as your business logic gets more complex, you need somewhere to put all that code. The choice between fat models and fat controllers is a least-bad kind of situation, which is why service objects have become a pretty common pattern for dealing with this problem. They allow you to keep both your controllers and models slim and focused on their most basic responsibilities.
Here’s the thing though: most developers end up designing their own service object patterns from scratch, which at best is unnecessary busy work and at worst ends up devolving into just a different kind of mess. The Interactor gem gives you a simple foundation to work from with strong conventions to guide your development. I’ll walk you through what makes it superior to rolling your own and show you how I like to use it in my applications.
What’s a service object?
A service object is just a plain old Ruby class that encapsulates a specific business operation - ex: “register a new user,” “process a payment,” or “generate a monthly report.” It’s the place you put complex logic that doesn’t naturally belong in a controller (which should just handle HTTP concerns) or a model (which should focus on data persistence and simple domain behavior).
Good service objects share a few key characteristics:
- They have a single, well-defined responsibility.
- They’re easier to test in isolation.
- They provide a clean interface that hides implementation details.
- The best ones follow naming conventions that tell you exactly what you can expect to happen when they’re called. Ex:
OrdersController#create
is a little vague, while class names likeOrders::Create
andPayments::ChargeForOrder
are much clearer.
Why Interactor?
So if service objects are so simple, then why add another gem to your application just to get those benefits?
Because just like Rails provides me with a structure for my application and provides elegant solutions for some of the most common web development problems, Interactor does the same thing for service objects.
A standardized interface and conventions
Service objects are conceptually as simple as they come - a name, a set of inputs, and a range of expected outputs and exceptions. There’s no agreed-upon standard interface, so developers have come up with all kinds of different approaches and patterns for implementing them.
1
2
3
4
5
6
7
8
9
10
11
12
# New instance + execute
UserCreator.new(params).execute
# Class method-based invocations
EmailSender.call(user, template)
OrderProcessor.process!(order_data)
PaymentHandler.perform(payment_data)
# Clusters of functions under a single class
ReportService.generate_inventory_report
ReportService.create_new_user_report
ReportService.export_marketing_data_dump
With Interactor, you’re guaranteed that every service object will follow the same rules and work the same way:
1
2
3
4
5
6
7
8
9
10
11
result = Users::Register.call(email: "user@example.com", name: "John")
if result.success?
@user = result.user
redirect_to root_path
elsif result.user && result.user.errors.any?
render :new_user_registration
else
flash[:alert] = result.message
redirect_to new_user_session_path
end
Every interactor is invoked using the call
class method, and there’s a context object which acts as both input and output, carrying data through the entire operation. You pass in parameters as a hash, and the interactor adds results or error messages to that same context. Need to check if it worked? Call result.success?
or result.failure?
. Want the error message? It’s in result.message
.
Composition of complex operations from simpler ones
The sorts of interactions where you really need service objects tend to be fractal in nature with basic operations being used to build up more involved ones. Approaching these kinds of scenarios without a framework tends to end either in enormous service object classes that other developers are afraid to touch or in complicated orchestrations of smaller classes and manual handling of individual error cases. Interactor, though, provides a way of breaking complex workflows into small, focused, manageable chunks.
1
2
3
4
5
6
7
8
9
10
class Orders::Process
include Interactor::Organizer
organize Orders::Validate,
Inventory::ReserveForOrder,
Orders::Create,
Payments::ChargeForOrder,
Emails::SendOrderConfirmation,
Warehouse::FulfillOrder
end
Organizers are composites and interactors in their own right where each step in the chain receives the same context object and builds on the previous work. In this example, the first interactor might validate the order contents, while the second might check stock levels and add an InventoryReservation
to the context, and so on. If any single step fails, then the entire chain stops immediately, and the organizer itself fails.
1
2
3
4
5
6
7
8
9
10
11
12
result = Orders::Process.call(
user: current_user,
items: current_cart.items,
payment_method: payment_method
)
if result.success?
redirect_to order_succeeded_path(result.order)
else
flash[:alert] = result.message
redirect_to order_confirmation_path
end
Each interactor gets to stay focused on a single responsibility, and you can use them as building blocks for whatever orchestrations you might dream up.
Hooks and lifecycle management
Another thing that tends not to make it into custom service objects is any sort of pattern for pre- and post-execution code. A class whose sole responsibility is implementing your business logic should remain focused on that. Cluttering it up with code for instrumentation, logging, and other stuff only distracts from that purpose.
Interactor provides before
, after
, and around
hooks that let you extract these secondary concerns from your call
method.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
class Payments::ChargeForOrder
include Interactor
before do
PaymentLogger.start(context.amount, context.user_id)
end
around do
context.start_time = Time.current
interactor.call
context.end_time = Time.current
context.elapsed_time = context.end_time - context.start_time
end
def call
# Just the payment logic - no extra noise
context.payment = PaymentGateway.charge(
amount: context.amount,
source: context.payment_source
)
context.fail!(message: "Payment declined") if context.payment.declined?
end
end
The call method stays focused on what actually matters while the hooks handle everything else. Need to log every payment attempt? Add a before
hook. Want to record metrics? Throw in an after
hook. The intent of the class remain clear.
Rollback functionality
One of the most valuable features when dealing with multi-step operations is Interactor’s automatic rollback capability. When a chain of interactors fails partway through, each completed step can define how to undo its changes.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
class Payments::ChargeForOrder
include Interactor
def call
# ...
context.fail!(message: "Payment declined") if context.payment.declined?
context.fail!(message: "Fraud risk") if context.payment_source.blacklisted?
end
def rollback
if context.payment&.succeeded?
PaymentGateway.refund(context.payment)
PaymentLogger.log_rollback(context.payment.id)
end
end
end
Being able to deal with each step in the process individually is super handy. Imagine a case where a our order processing example is implemented as a single monolithic service object. The error handling in a case like this would either be a single massive method attempting to handle all possible failure cases (and probably buggy and leaky as hell) or it would just be missing. That’s how you end up with data integrity issues and those fun late-night debugging sessions where something failed halfway through and left your system in a weird state.
Best practices and guidelines for use
So hopefully I’ve made a case for using Interactor or something like it as the basis for your service layer.
Make service objects a first class part of your application.
Establishing dedicated subdirectory for your service classes, whether that’s app/interactors
or the more generic app/services
sends a clear signal to your team about your application architecture and development standards.
Service objects for basic CRUD operations are overkill.
If some is good, then more is better, right? There is such a thing as taking a good thing too far. Here’s what I mean:
1
2
3
4
5
class User::Destroy < ApplicationInteractor
def call
context.user.destroy || context.fail!(message: "User not destroyed")
end
end
While this is obviously ridiculous and only adds a layer between a Rails controller action or background job and the model, it shows that you shouldn’t be too dogmatic about service objects. You should instead…
Look for opportunities to extract an interactor from an existing class.
None of us (I hope) sets out to write a 50-line controller action, and yet they exist. You start with a simple create
action that handles user registration, then you add:
- Email verification
- Account setup
- CRM updates
- Welcome email delivery
And before you know it you’ve got a mess on your hands.
The same thing happens with model methods - what starts as a simple calculate_score
method gradually accumulates edge cases and special handling until it’s doing way too much. Bloated methods like these are prime candidates for extraction into interactors, and the process usually reveals natural boundaries between different responsibilities that weren’t obvious when everything was jammed together.
Build on the foundation that something like Interactor provides.
On new projects, Interactor is one of those first-day gems that I install right away, knowing that I’ll need it eventually. And because I’ve got a lot of experience using it, I’ve identified a bunch of common use cases for interactors like:
- Ensure that specific parameters are included in the initial context.
- Wrap an organizer and all its component interactors in a database transaction.
- Have the option of running the interactor either immediately or in a background job.
- Handle certain types of common exceptions.
- Log detailed instrumentation messages about interactor runs.
- Write tests for interactor classes with a minimum of boilerplate.
For each of these, I’ve got solutions that I can just drop into my app and use to turbo charge Interactor for the way I use it.
The Interactor gem eliminates a whole category of problems that you don’t realize you have until you’ve been burned by them a few times. Inconsistent interfaces, missing rollback logic, scattered cross-cutting concerns - these issues compound as your application grows. Rather than spending time building and debugging your own service object framework, you can leverage something that’s been battle-tested across thousands of applications. Your future self will thank you when you’re adding features instead of untangling homegrown abstractions.
Additional Resources
- Interactor gem documentation - Official docs and examples
- Interactor Rails gem - Rails-specific extensions and generators
- Sustainable Web Development with Ruby on Rails - A great resource about conscientious Rails development with some good-sense deep thinking about organizing business logic