DEV Community

Cover image for Servactory — Typed Service Objects with Declarative Actions for Ruby
Anton
Anton

Posted on

Servactory — Typed Service Objects with Declarative Actions for Ruby

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
Enter fullscreen mode Exit fullscreen mode

Notice how you can read the service top-to-bottom and immediately understand:

  1. What data it expects (input)
  2. What intermediate state it uses (internal)
  3. What it returns (output)
  4. 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
Enter fullscreen mode Exit fullscreen mode

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 }
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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: ""
Enter fullscreen mode Exit fullscreen mode

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"
Enter fullscreen mode Exit fullscreen mode

Then generate the base class:

rails g servactory:install
Enter fullscreen mode Exit fullscreen mode

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


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)