Ruby's flexibility is great — until every developer on the team writes service objects differently.
After years of working with Rails codebases where each service had its own structure, error handling, and testing conventions, I built Servactory — a framework that standardizes service objects through a typed, declarative DSL.
In this post I'll walk through the core ideas, show real-world examples, and explain what makes Servactory different from tools like Interactor, ActiveInteraction, or plain POROs.
The problem
If you've worked on a Rails team of any size, you've probably seen this: one developer puts business logic in a call method, another uses perform, a third returns a hash, a fourth raises exceptions. Testing conventions vary. Error handling is inconsistent. Onboarding new teammates means learning each service's personal philosophy.
I wanted one consistent pattern — with type safety, clear data flow, and a structure you can understand at a glance.
The core idea: three attribute layers
Servactory separates every service into three explicit attribute layers:
- Inputs — what comes in
- Internals — working state
- Outputs — what goes out
Each layer is a distinct declaration with its own namespace and type checking. Combined with declarative make calls that define action order, the data flow through a service becomes immediately visible.
A real-world example
Here's a payment processing service:
class Payments::Process < ApplicationService::Base
input :payment, type: Payment
internal :charge_result, type: Servactory::Result
output :payment, type: Payment
make :validate_status!
make :perform_request!
make :handle_response!
make :assign_payment
private
def validate_status!
return if inputs.payment.pending?
fail!(
message: "Payment has already been processed",
meta: { status: inputs.payment.status }
)
end
def perform_request!
internals.charge_result = Gateway::Charge.call(
amount: inputs.payment.amount,
token: inputs.payment.token
)
end
def handle_response!
internals.charge_result
.on_success { handle_success! }
.on_failure { handle_failure! }
end
def handle_success!
inputs.payment.complete!(internals.charge_result.response.id)
end
def handle_failure!
inputs.payment.fail!(internals.charge_result.error.message)
fail_result!(internals.charge_result)
end
def assign_payment
outputs.payment = inputs.payment
end
end
Notice how you can read the service top-to-bottom and immediately understand:
- What data it expects (
input) - What intermediate state it uses (
internal) - What it returns (
output) - What steps it performs and in what order (
make)
No digging through methods to trace the data flow.
Using it in a controller
The result object gives you a clean API:
class PaymentsController < ApplicationController
def complete
service = Payments::Process.call(payment: @payment)
if service.success?
redirect_to service.payment
else
flash.now[:alert] = service.error.message
render :new, status: :unprocessable_entity
end
end
end
The .on_success / .on_failure hooks work too:
Payments::Process
.call(payment: @payment)
.on_success { |outputs:| redirect_to outputs.payment }
.on_failure { |exception:| flash[:alert] = exception.message }
Action grouping with transactions and rollbacks
For complex workflows, stage blocks let you group actions with transactional wrapping and rollback handlers:
stage do
wrap_in(lambda do |methods:, context:|
ActiveRecord::Base.transaction { methods.call }
end)
rollback :clear_data_and_fail!
make :create_user!
make :create_blog_for_user!
make :create_post_for_user_blog!
end
# ...
def clear_data_and_fail!(e)
fail!(message: "Failed to create data: #{e.message}")
end
If any action in the group raises, the transaction is rolled back and the rollback method is called with the exception. No more manual rescue blocks scattered across your service.
Input validation built in
Servactory comes with a rich validation DSL. A few examples:
input :event_name,
type: String,
inclusion: %w[created rejected approved]
input :email,
type: String,
must: {
be_valid_email: {
is: ->(value:, input:) { value.match?(URI::MailTo::EMAIL_REGEXP) },
message: "Invalid email format"
}
}
input :middle_name,
:optional,
type: String,
default: ""
Validation errors are structured and include metadata — making them easy to handle in your UI or API layer.
What makes it worth trying
Type safety on all three layers. Inputs, internals, and outputs are each type-checked independently. Catch bugs before they become production issues.
Explicit data flow. The separation into three namespaces (inputs.*, internals.*, outputs.*) eliminates "where does this value come from?" confusion.
Structured failure handling. fail! with metadata, typed failures, fail_result! for error propagation, on_success / on_failure hooks on the result object.
Action grouping. stage blocks with wrap_in for transactions, only_if conditions, and rollback handlers.
Full ecosystem. Custom RuboCop cops, OpenTelemetry instrumentation, RSpec matchers, and Rails generators.
Getting started
Add to your Gemfile:
gem "servactory"
Then generate the base class:
rails g servactory:install
That's it. Define your first service and you're up and running.
Numbers
- Ruby 3.2+, Rails 5.1 through 8.1
- 110,000+ gem downloads
- 138 releases
- MIT licensed
- In production for over 2 years
Links
- 📚 Documentation: servactory.com
- 💻 GitHub: github.com/servactory/servactory
- 💎 RubyGems: rubygems.org/gems/servactory
I'd love to hear your feedback — especially if you've tried other approaches like Interactor, ActiveInteraction, Trailblazer, or plain POROs. What worked? What didn't? Let me know in the comments.
Top comments (0)