Railsmith

Railsmith helps you manage complex multi-step workflows in Rails apps — the kind where business logic spans multiple models, touches external services, and needs clear success/failure handling at every step.

It is not a replacement for Rails models or controllers. It's for the cases where those alone aren't enough.

Requirements: Ruby 3.1–3.3, Rails 7.0–8.x


Structured results, always

Every service call returns a Railsmith::Result. Success or failure, the shape is always the same — no exceptions to rescue, no inconsistent return values.

result = OrderService.call(action: :create, params: order_params, context: ctx)

if result.success?
  render json: result.value, status: :created
else
  render json: result.error.to_h, status: :unprocessable_entity
end
result.success?       # => true / false
result.value          # => the returned object or data
result.error.code     # => "validation_error" | "not_found" | "conflict" | "unauthorized" | "unexpected"
result.error.message  # => human-readable message
result.error.details  # => structured detail hash (e.g. field-level validation errors)

This is the part of Railsmith that applies everywhere, even if you adopt nothing else.


Is this for you?

If your Rails app is small and moving fast, you probably don't need this. Reach for models first. Active Record callbacks, validations, and scopes handle a lot, and adding abstraction too early is a real cost.

Railsmith is extracted from a production app that crossed the threshold where that stopped being enough. The inflection point looked like this:

  • Multi-step workflows — validate cart → reserve inventory → charge payment → create order — each step needing independent rollback on failure
  • accepts_nested_attributes_for silently skipping service-level validations on associated records
  • No consistent answer to "what does this action return when it fails?"
  • Cross-domain calls (billing code touching identity models) with no warning and no audit trail
  • Business logic scattered across callbacks, controllers, and models with no single place to look

If several of those sound familiar, Railsmith gives you a single, consistent pattern for all of them. If they don't, you're probably not at the inflection point yet.


The core idea

Every model access goes through a service. The service is the only place domain logic lives.

Before — logic split between controller and model, inconsistent error handling:

# controller
def create
  @order = Order.new(order_params)
  @order.line_items.build(line_item_params)  # bypasses LineItem validations you care about
  if @order.save
    PaymentGateway.charge(@order)            # exception if it fails — now what?
    render json: @order, status: :created
  else
    render json: @order.errors, status: :unprocessable_entity
  end
end

After — one call, one result, everything in the right place:

# controller
def create
  result = OrderService.call!(action: :create, params: order_params, context: ctx)
  render json: result.value, status: :created
end
# OrderService handles nested line items through LineItemService (hooks, validations intact),
# payment charging, rollback on failure, and returns a structured Result — no rescue needed.

Installation

# Gemfile
gem "railsmith"
bundle install
rails generate railsmith:install

The install generator creates config/initializers/railsmith.rb and the app/services/ directory tree.


Quick Start

Generate a service for a model:

rails generate railsmith:model_service User

Call it:

result = UserService.call(
  action: :create,
  params: { attributes: { name: "Alice", email: "alice@example.com" } }
)

if result.success?
  puts result.value.id
else
  puts result.error.message   # => "Validation failed"
  puts result.error.details   # => { errors: { email: ["is invalid"] } }
end

See docs/quickstart.md for a full walkthrough.


Examples (Sample App + Smoke Scripts)

For runnable examples that exercise Railsmith::Pipeline and services the way a small app would (without shipping a full Rails skeleton in this gem repo), see the companion repository:

It includes smoke scripts for checkout pipelines and async nested writes against both in-process and real queue backends (Redis/Postgres/RabbitMQ).


Declarative Inputs

Declare expected parameters with types, defaults, and constraints using the input DSL. Railsmith coerces, validates, and filters params automatically before the action runs.

class UserService < Railsmith::BaseService
  model User
  domain :identity

  input :email,    String,   required: true, transform: ->(v) { v.strip.downcase }
  input :age,      Integer,  default: nil
  input :role,     String,   in: %w[admin member guest], default: "member"
  input :active,   :boolean, default: true
  input :metadata, Hash,     default: -> { {} }
end
  • Type coercion — strings to integers, booleans, dates, and more
  • Validation — required fields, allowed value lists, coercion failures all return structured validation_error results
  • Input filtering — only declared keys reach the action (mass-assignment protection)
  • Inheritance — subclasses inherit parent inputs and can extend or override independently

See docs/inputs.md for the full reference.


Association Support

Declare associations at the service level for eager loading, nested CRUD, and cascading destroy. Nested writes delegate to each associated service class — so every service's input validation, lifecycle hooks, and custom logic run as if you had called that service directly.

class OrderService < Railsmith::BaseService
  model Order
  domain :commerce

  has_many   :line_items,   service: LineItemService, dependent: :destroy
  has_many   :audit_events, service: AuditEventService, async: true
  belongs_to :customer,     service: CustomerService, optional: true

  includes :line_items, :customer
end

Nested create — pass associated records in params; the foreign key is injected automatically:

OrderService.call(
  action: :create,
  params: {
    attributes: { total: 99.99, customer_id: 7 },
    line_items: [
      { attributes: { product_id: 1, qty: 2, price: 29.99 } }
    ]
  },
  context: ctx
)

By default, nested writes run in the parent's transaction; any failure rolls back everything. Associations declared with async: true enqueue a background job after the parent commits instead — see Async nested writes.

See docs/associations.md for the full reference (including dependent: modes and async configuration).


Service Pipelines

Chain multiple services into a sequential workflow with fail-fast semantics, automatic param forwarding, rollback/compensation, conditional steps, and built-in instrumentation.

class CheckoutPipeline < Railsmith::Pipeline
  domain :commerce

  step :validate_cart,     service: CartService,         action: :validate
  step :reserve_inventory, service: InventoryService,    action: :reserve,
                           rollback: :unreserve
  step :charge_payment,    service: PaymentService,      action: :charge,
                           inputs: { amount: :cart_total }, rollback: :refund
  step :create_order,      service: OrderService,        action: :create
  step :send_confirmation, service: NotificationService, action: :send_receipt,
                           on_failure_continue: true
end

result = CheckoutPipeline.call(params: { cart_id: 42, user_id: 7 }, context: ctx)
result.meta[:pipeline_step]  # => :charge_payment (on failure)

Each step's Hash result.value is merged into accumulated params so the next step receives all data gathered so far. On failure, completed steps are rolled back in reverse order. Conditional steps (if: / unless:) are skipped cleanly without affecting the rollback sequence.

See docs/pipelines.md for the full reference.


Lifecycle Hooks

Attach before, after, and around callbacks to any service action for cross-cutting concerns — audit logging, event publishing, metrics, authorization:

class OrderService < Railsmith::BaseService
  model Order

  before :create, :update, :destroy, name: :audit_log do
    AuditLog.record(actor: context[:actor_id], service: self.class.name)
  end

  after :create do |result|
    EventBus.publish("order.created", result.value) if result.success?
  end

  around :charge do |action|
    Metrics.time("order.charge") { action.call }
  end
end

Apply hooks globally across all services (or all services in a domain) via Railsmith.configure:

Railsmith.configure do |config|
  config.before_action :create do
    RateLimiter.check!(context[:actor_id])
  end

  config.around_action :charge, only: [:commerce] do |action|
    CommerceSandbox.wrap { action.call }
  end
end

See docs/hooks.md for the full reference.


Result Chaining

Compose services without a full pipeline using the fluent Result API:

result = CartService.call(action: :validate, params: { cart_id: 42 }, context: ctx)
  .and_then { |data| PaymentService.call(action: :charge, params: data, context: ctx) }
  .and_then { |data| OrderService.call(action: :create, params: data, context: ctx) }
  .on_success { |data| EventBus.publish("order.created", data) }
  .on_failure { |err|  ErrorTracker.capture(err) }
Method Fires when Returns
`and_then { \ value\ }`
`or_else { \ error\ }`
`on_success { \ value\ }`
`on_failure { \ error\ }`

call! — Raising Variant

call! raises Railsmith::Failure instead of returning a failure result. Use it in controllers with rescue_from:

class ApplicationController < ActionController::API
  include Railsmith::ControllerHelpers
  # Catches Railsmith::Failure and renders JSON with the correct HTTP status
end

class UsersController < ApplicationController
  def create
    result = UserService.call!(action: :create, params: { attributes: user_params }, context: ctx)
    render json: result.value, status: :created
  end
end

Railsmith::Failure carries the full structured result for inspection in rescue handlers:

rescue Railsmith::Failure => e
  e.code    # => "validation_error"
  e.result  # => Railsmith::Result (failure)
end

See docs/call-bang.md for the full reference.


Generators

Command Output
rails g railsmith:install Initializer + service directories
rails g railsmith:domain Billing app/domains/billing.rb + subdirectories
rails g railsmith:model_service User app/services/user_service.rb
rails g railsmith:model_service User --inputs Service with input DSL (introspects model columns)
rails g railsmith:model_service Order --associations Service with association DSL (introspects model associations)
rails g railsmith:model_service Billing::Invoice --domain=Billing app/domains/billing/services/invoice_service.rb
rails g railsmith:operation Billing::Invoices::Create app/domains/billing/invoices/create.rb
rails g railsmith:pipeline Checkout app/pipelines/checkout_pipeline.rb + spec
rake railsmith:pipelines List all pipeline classes and their declared steps

CRUD Actions

CRUD defaults exist to make domain enforcement practical. If every model access must go through a service — so arch checks, cross-domain guards, and lifecycle hooks fire consistently — you'd otherwise hand-write boilerplate services for every model. The defaults give you a compliant service for free; override only what needs custom logic.

Services that declare a model inherit create, update, destroy, find, and list with automatic exception mapping:

class UserService < Railsmith::BaseService
  model(User)
end

# create (context: is optional from 1.1 onward)
UserService.call(action: :create, params: { attributes: { email: "a@b.com" } })

# update
UserService.call(action: :update, params: { id: 1, attributes: { email: "new@b.com" } })

# destroy
UserService.call(action: :destroy, params: { id: 1 })

Common ActiveRecord exceptions (RecordNotFound, RecordInvalid, RecordNotUnique) are caught and converted to structured failure results automatically.


Bulk Operations

# bulk_create
UserService.call(
  action: :bulk_create,
  params: {
    items: [{ name: "Alice", email: "a@b.com" }, { name: "Bob", email: "b@b.com" }],
    transaction_mode: :best_effort  # or :all_or_nothing
  }
)

# bulk_update
UserService.call(
  action: :bulk_update,
  params: { items: [{ id: 1, attributes: { name: "Alice Smith" } }] }
)

# bulk_destroy
UserService.call(
  action: :bulk_destroy,
  params: { items: [1, 2, 3] }
)

All bulk results include a summary (total, success_count, failure_count, all_succeeded) and per-item detail. See docs/cookbook.md for the full result shape.


Domain Boundaries

Tag services with a bounded context and track it through all calls:

rails generate railsmith:domain Billing
rails generate railsmith:model_service Billing::Invoice --domain=Billing
module Billing
  module Services
    class InvoiceService < Railsmith::BaseService
      model(Billing::Invoice)
      domain :billing
    end
  end
end

Pass context when you need domain or tracing data (context: is optional; omit it to use thread-local Context.current or an auto-built context):

ctx = Railsmith::Context.new(domain: :billing, request_id: "req-abc")

Billing::Services::InvoiceService.call(action: :create, params: { ... }, context: ctx)

Request ID (X-Request-Id)

If you omit request_id, Railsmith auto-generates one — which does not match the X-Request-Id header ActionDispatch exposes on the request. To align service instrumentation with your load balancer or upstream caller, pass the request id from the controller.

Include Railsmith::ControllerHelpers and use railsmith_context (it sets request_id from request.request_id):

class OrdersController < ApplicationController
  include Railsmith::ControllerHelpers

  def create
    result = OrderService.call!(
      action: :create,
      params: { attributes: order_params },
      context: railsmith_context(domain: :commerce, actor_id: current_user.id)
    )
    render json: result.value, status: :created
  end
end

You can also set context once per request with Railsmith::Context.with (for example in around_action), including request_id: request.request_id, so every service call without an explicit context: inherits it. See docs/call-bang.md.

When the context domain differs from a service's declared domain, Railsmith emits a cross_domain.warning.railsmith instrumentation event. The payload includes log_json_line and log_kv_line (from Railsmith::CrossDomainWarningFormatter) for structured logging; when strict_mode is true, on_cross_domain_violation receives the same payload.

Configure enforcement in config/initializers/railsmith.rb:

Railsmith.configure do |config|
  config.warn_on_cross_domain_calls = true   # default
  config.strict_mode = false
  config.on_cross_domain_violation = ->(payload) { ... }
  config.cross_domain_allowlist = [{ from: :catalog, to: :billing }]

  # Required if any association uses async: true (see docs/associations.md)
  # config.async_job_class = Railsmith::AsyncNestedWriteJob
end

Error Types

Code Factory
validation_error Railsmith::Errors.validation_error(message:, details:)
not_found Railsmith::Errors.not_found(message:, details:)
conflict Railsmith::Errors.conflict(message:, details:)
unauthorized Railsmith::Errors.unauthorized(message:, details:)
unexpected Railsmith::Errors.unexpected(message:, details:)

Architecture Checks

Detect controllers that access models directly (and related service-layer rules). From the shell:

rake railsmith:arch_check
RAILSMITH_FORMAT=json rake railsmith:arch_check
RAILSMITH_FAIL_ON_ARCH_VIOLATIONS=true rake railsmith:arch_check

From Ruby (same environment variables and exit codes as the task), after require "railsmith/arch_checks":

Railsmith::ArchChecks::Cli.run # => 0 or 1

See Migration for optional env:, output:, and warn_proc: arguments.


Documentation

  • Quickstart — install, generate, first call
  • Inputs — declarative input DSL, type coercion, filtering, custom coercions
  • Associations — association DSL, eager loading, nested CRUD, cascading destroy, async nested writes
  • Pipelines — sequential service composition, param forwarding, rollback, conditional steps, instrumentation
  • Hooks — before/after/around DSL, conditional hooks, inheritance, global hooks, introspection
  • Pipelines (guides path) / Hooks (guides path) — stubs linking to the canonical guides above
  • call! — raising variant, controller integration, ControllerHelpers, railsmith_context / request IDs
  • Cookbook — CRUD, bulk, inputs, associations, domain context, error mapping, observability
  • Legacy Adoption Guide — incremental migration strategy
  • Migration — upgrading from any 1.x release
  • Changelog

Development

bin/setup       # install dependencies
bundle exec rake spec   # run tests
bin/console     # interactive prompt

Multi-Rails matrix (Appraisal)

Appraisal gemfiles live under gemfiles/. After bundle install, generate or refresh lockfiles with bundle exec appraisal install. Run the suite against each combination:

bundle exec appraisal rails-7-0 rake spec
bundle exec appraisal rails-8-0 rake spec
# or: bundle exec appraisal rake spec   # runs the default task for each gemfile

To reproduce one cell without the Appraisal CLI:

BUNDLE_GEMFILE=gemfiles/rails_7_0.gemfile bundle install
BUNDLE_GEMFILE=gemfiles/rails_7_0.gemfile bundle exec rspec

Benchmark (optional)

  • ruby benchmarks/pipeline_overhead.rb — coarse timing of pipeline vs sequential calls (see script header).

To install locally: bundle exec rake install.


Contributing

Bug reports and pull requests are welcome at github.com/samaswin/railsmith.

License

MIT License.