Serviced organizes your business logic into small, explicit service objects. Inputs are typed, immutable, and validated. Every call returns an honest Success or Failure. Services compose into flows that run with or without a transaction. That is the whole gem.
class RegisterUser < Serviced::Service
attribute :email, :string
attribute :password, :string
attribute :plan, :string, default: "free"
validates :email, presence: true, format: { with: URI::MailTo::EMAIL_REGEXP }
validates :password, length: { minimum: 8 }
def call
success(User.create!(email:, password:, plan:))
rescue ActiveRecord::RecordNotUnique
failure(:email_taken, "That email is already registered")
end
end
RegisterUser.call(email: "ada@example.com", password: "correct horse")
.on_success { |user| UserMailer.welcome(user).deliver_later }
.on_failure { |failure| Rails.logger.warn(failure.) }
Table of contents
- Why serviced
- Installation
- Getting started
- Services
- Results
- Flows
- Queries
- Cookbook
- Configuration
- Testing your services
- Philosophy
- Contributing
- License
Why serviced
Business logic has to live somewhere. Left alone, it spreads across fat controllers and fatter models: params get coerced by hand, validations happen in three places, and the return value of a "service" might be a record, a boolean, nil, or an exception depending on the day.
# Before: everything in the controller
def create
amount = params[:amount].to_i
return render_error("amount required") if amount <= 0
from = Account.find(params[:from_id])
to = Account.find(params[:to_id])
return render_error("insufficient funds") if from.balance_cents < amount
ActiveRecord::Base.transaction do
from.update!(balance_cents: from.balance_cents - amount)
to.update!(balance_cents: to.balance_cents + amount)
Ledger.create!(from:, to:, amount_cents: amount)
end
redirect_to account_path(from)
rescue => e
render_error(e.)
end
# After: the controller delegates, the contract lives in the flow
def create
TransferFunds.call(from_id: params[:from_id], to_id: params[:to_id], amount_cents: params[:amount])
.on_success { |ctx| redirect_to account_path(ctx[:from]) }
.on_failure { |failure| render_error(failure.) }
end
The typing, the validation, the branching, and the transaction now live in one named, testable place. That is what serviced gives you:
- Typed, immutable inputs. Declared with types, coerced on the way in, frozen afterwards. The contract lives in the service, not the controller.
- A mandatory Success/Failure result. No guessing what a call returns.
- Composable flows. Chain services into a pipeline, with or without a single transaction.
- Query objects. A typed home for the gnarly reads, returning a composable relation.
Installation
# Gemfile
gem "serviced"
bundle install
Serviced depends only on activemodel, so it works in any Ruby project. The transactional flow feature uses ActiveRecord when it is available (see Configuration).
Getting started
A service declares its inputs, validates them, and implements #call. #call must return a result, built with the success and failure helpers.
class SubscribeToNewsletter < Serviced::Service
attribute :email, :string
validates :email, presence: true, format: { with: URI::MailTo::EMAIL_REGEXP }
def call
subscriber = Subscriber.find_or_initialize_by(email:)
return failure(:already_subscribed) if subscriber.persisted?
subscriber.save!
success(subscriber)
end
end
Call it with a hash of attributes. You always get back a result.
result = SubscribeToNewsletter.call(email: "grace@example.com")
result.success? # => true
result.value # => #<Subscriber ...>
# an invalid call never reaches #call:
SubscribeToNewsletter.call(email: "nope").failure? # => true
Services
Typed inputs
Attributes are declared with the ActiveModel attribute DSL, so string params from a request are coerced to the type you asked for.
class BuildReport < Serviced::Service
attribute :account_id, :integer
attribute :from, :date
attribute :limit, :integer, default: 50
attribute :verbose, :boolean, default: false
attribute :tags # no type: accepts any object
end
report = BuildReport.new(account_id: "42", from: "2026-01-01", verbose: "1")
report.account_id # => 42 (Integer)
report.from # => Date (Date)
report.verbose # => true (TrueClass)
report.limit # => 50 (default)
Unknown keys are ignored, which is what lets a service drop cleanly into a flow without matching the exact shape of the context.
Types are not just scalars. A symbol coerces the value through an ActiveModel type; a class requires the value to be an instance of it, so you can type an attribute as an ActiveRecord record, a plain object, or anything:
class ChargeSubscription < Serviced::Service
attribute :account, Account # must be an Account (record, PORO, subclass), or nil
attribute :coupon, Coupon # must be a Coupon, or nil
attribute :cents, :integer # coerced to Integer
def call
# account and coupon are guaranteed to be the right type here
success(account.charge!(cents, coupon:))
end
end
ChargeSubscription.call(account: "oops", cents: 500)
# => Failure(:invalid), errors: ["Account must be an instance of Account"]
The check allows nil (make it required with validates :account, presence: true) and accepts subclasses. The object is shared by reference, not coerced, so a record stays the same record.
Immutability that actually holds
Inputs are read-only. There is no writer to reassign them:
report.limit = 100 # => NoMethodError: private method 'limit=' called
More importantly, inputs are isolated by default. At construction each value is captured as an immutable snapshot, so a mutation somewhere else in the code cannot reach into your service and change what it already read.
class CalculateQuote < Serviced::Service
attribute :items # array of { name:, cents: }
def call
success(total_cents: items.sum { |item| item[:cents] })
end
end
cart = [{ name: "Keyboard", cents: 8_900 }]
quote = CalculateQuote.new(items: cart)
cart << { name: "Surprise fee", cents: 9_900 } # a bug elsewhere mutates the caller's array
quote.items # => [{ name: "Keyboard", cents: 8900 }] the snapshot is untouched
quote.items << {} # => FrozenError
Arrays, hashes, sets, and strings are deep-copied and deep-frozen. Objects with identity (an ActiveRecord record, say) are shared by reference and left alone, because a copy of a record is a different, unsaved object. When you deliberately want to share a mutable value, opt out:
class CollectWarnings < Serviced::Service
attribute :row
attribute :warnings, isolate: false # shared: the caller reads it back
def call
warnings << "missing email" if row[:email].blank?
success(row)
end
end
Validation
Use the full ActiveModel validation DSL. Invalid inputs never reach your #call. They short-circuit to a failure whose reason is :invalid and whose error is the ActiveModel::Errors object.
class CreateProject < Serviced::Service
attribute :name, :string
attribute :budget_cents, :integer
validates :name, presence: true
validates :budget_cents, numericality: { greater_than: 0 }
def call
success(Project.create!(name:, budget_cents:))
end
end
result = CreateProject.call(name: "", budget_cents: -5)
result.reason # => :invalid
result.error. # => ["Name can't be blank", "Budget cents must be greater than 0"]
Results
A result is always a Serviced::Success or a Serviced::Failure, and it is frozen. Here is how a service resolves:
flowchart LR
IN([".call(attributes)"]) --> T["typed, frozen inputs"]
T --> V{"valid?"}
V -- no --> INV["Failure(:invalid)"]
V -- yes --> C["#call"]
C --> OK["Success(value)"]
C --> ERR["Failure(reason, message)"]
| Method | Success | Failure |
|---|---|---|
success? / failure? |
true/false |
false/true |
value |
the payload | nil |
value! |
the payload | raises InvalidResultAccess |
reason |
not defined | a Symbol for branching |
message |
not defined | a String or nil |
error |
not defined | an exception, error object, etc. |
Build them inside a service with the success and failure helpers:
success(order) # a value
success(order: order) # a hash value (feeds a flow context)
failure(:sold_out) # reason only
failure(:sold_out, "No tickets left") # reason plus message
failure(:declined, "Card declined", error: gateway_error)
Callbacks, chaining, and pattern matching
# Callbacks return self, so they chain
result.on_success { |value| ... }.on_failure { |failure| ... }
# Railway chaining: runs only while successful
CreateProject.call(params)
.and_then { |project| InviteOwner.call(project:) }
.and_then { |project| SeedDefaultTasks.call(project:) }
# Transform a success value
CreateProject.call(params).map { |project| ProjectPresenter.new(project) }
# Pattern matching
case PlaceOrder.call(cart:)
in Serviced::Success(value:)
redirect_to order_path(value)
in Serviced::Failure(reason: :sold_out)
redirect_to waitlist_path
in Serviced::Failure(reason:, message:)
render_error(message)
end
Controllers get a lot quieter:
def create
PlaceOrder.call(cart: current_cart, payment_token: params[:token])
.on_success { |ctx| redirect_to order_path(ctx[:order]) }
.on_failure { |failure| render_error(failure., status: status_for(failure.reason)) }
end
Flows
A flow runs steps in order. Each step receives the current context and, on success, contributes a hash that is merged into the context for the next step. The first failing step halts the flow and its failure is returned.
class PlaceOrder < Serviced::Flow
step ValidateCart # success(cart: cart)
step ReserveStock # reads :cart, success(reservation: ...)
step CreateOrder # success(order: order)
step SendReceipt
end
result = PlaceOrder.call(cart: current_cart)
result.value # => { cart: #<Cart>, reservation: ..., order: #<Order> }
flowchart LR
CTX([context]) --> A[ValidateCart]
A -->|success| B[ReserveStock]
B -->|success| C[CreateOrder]
C -->|success| D[SendReceipt]
D -->|success| OK([Success: merged context])
A -. failure .-> STOP([return the failure])
B -. failure .-> STOP
C -. failure .-> STOP
D -. failure .-> STOP
A step is anything that responds to call(context) and returns a result, so a lambda works too:
class PlaceOrder < Serviced::Flow
step ValidateCart
step ->(context) { Serviced::Success.new(context.merge(audited_at: Time.current)) }
end
You can also build a flow inline:
PlaceOrder = Serviced::Flow.define do
step ValidateCart
step ReserveStock
end
Transactions
Mark a flow transactional and every step runs inside one database transaction. If any step returns a failure or raises, the whole thing rolls back. This is the textbook money transfer, done right:
class LoadAccounts < Serviced::Service
attribute :from_id, :integer
attribute :to_id, :integer
def call
success(from: Account.find(from_id), to: Account.find(to_id))
end
end
class EnsureFunds < Serviced::Service
attribute :from
attribute :amount_cents, :integer
def call
return failure(:insufficient_funds, "Not enough balance") if from.balance_cents < amount_cents
success
end
end
class TransferFunds < Serviced::Flow
transactional
step LoadAccounts
step EnsureFunds
step Debit # from.update!(balance_cents: from.balance_cents - amount_cents)
step Credit # to.update!(balance_cents: to.balance_cents + amount_cents)
step RecordLedger
end
TransferFunds.call(from_id: 1, to_id: 2, amount_cents: 5_00)
If Credit fails or RecordLedger raises, the debit is rolled back. Expected failures roll back and return the failure. Unexpected exceptions roll back and propagate, so bugs stay visible.
Queries
A query object is a typed home for a complex read. It returns an ActiveRecord::Relation (or a value) instead of a result, so what comes back stays composable: callers can still paginate it, add includes, or chain more scopes.
class OverdueInvoicesQuery < Serviced::Query
attribute :account
attribute :as_of, :date
attribute :min_cents, :integer, default: 0
validates :as_of, presence: true
def call
account.invoices
.unpaid
.where(total_cents: min_cents..)
.where(due_on: ...as_of)
.order(:due_on)
end
end
relation = OverdueInvoicesQuery.call(account:, as_of: Date.current)
relation.page(params[:page]).per(25) # still a relation
Invalid inputs raise Serviced::InvalidQuery (a query has no failure channel, so bad input is a bug). For queries that drop into raw SQL, the base ships safe helpers so you stop hand-rolling connection.quote:
class RankedProductsQuery < Serviced::Query
attribute :scope
attribute :order_by, :string, default: "created_at"
def call
scope.order(Arel.sql("#{quote_column(order_by)} DESC"))
end
def total
count_of(scope) # SELECT COUNT(*) FROM (<scope>) without loading rows
end
end
Keep the split clean: a query reads and returns a relation, a service does something and returns a result. A service consumes a query and wraps it, which is also how a query reaches a flow (through the service, since a step returns a result).
Cookbook
Short, real recipes across common domains.
Onboard a new team (flow).
class OnboardTeam < Serviced::Flow
step CreateAccount # success(account: account)
step CreateOwner # reads :account, success(owner: owner)
step SeedSampleData
step SendWelcomeEmail
end
Publish a blog post, atomically (transactional flow).
class PublishPost < Serviced::Flow
transactional
step ValidateDraft
step RenderMarkdown # success(html: html)
step MarkPublished # post.update!(published_at: Time.current)
step PingSearchIndex
end
Cancel a subscription with a refund (transaction protects both writes).
class CancelSubscription < Serviced::Flow
transactional
step LoadSubscription
step RefundLastCharge # failure(:refund_failed, ...) rolls everything back
step EndSubscription
end
Import a CSV row (service with a shared error sink).
class ImportContactRow < Serviced::Service
attribute :row
attribute :errors, isolate: false # shared: the importer collects these
validates :row, presence: true
def call
contact = Contact.new(name: row["name"], email: row["email"])
return failure(:invalid_row, error: contact.errors) unless contact.save
success(contact)
end
end
Guard, then act (railway chaining without a flow).
AuthorizePayment.call(order:)
.and_then { |auth| CapturePayment.call(auth:) }
.and_then { |charge| FulfillOrder.call(charge:) }
.on_failure { |failure| NotifyOps.call(failure:) }
Configuration
Transactional flows need a transaction handler. When ActiveRecord is loaded, serviced defaults to ActiveRecord::Base.transaction, so a Rails app needs no setup. To use a different mechanism, configure one:
Serviced.configure do |config|
config.transaction_handler = ->(&block) { Sequel::DATABASE.transaction(&block) }
end
The handler must run the block inside a transaction and roll back when the block raises. If a transactional flow runs without a handler, it raises Serviced::MissingTransactionHandler.
Testing your services
Services are plain objects with a single entry point, so they are a pleasure to test. Assert on the result, not on side effects you had to reach for.
RSpec.describe TransferFunds do
it "moves money between accounts" do
from = create(:account, balance_cents: 10_00)
to = create(:account, balance_cents: 0)
result = described_class.call(from_id: from.id, to_id: to.id, amount_cents: 4_00)
expect(result).to be_success
expect(from.reload.balance_cents).to eq(6_00)
expect(to.reload.balance_cents).to eq(4_00)
end
it "does not move money when funds are short" do
from = create(:account, balance_cents: 1_00)
to = create(:account, balance_cents: 0)
result = described_class.call(from_id: from.id, to_id: to.id, amount_cents: 9_00)
expect(result).to be_failure
expect(result.reason).to eq(:insufficient_funds)
expect(from.reload.balance_cents).to eq(1_00) # rolled back
end
end
Philosophy
- Inputs are read-only, isolated value objects. Compute derived state in
#call, do not mutate inputs. #callmust return a result. Returning anything else raisesServiced::ResultTypeError, so the contract cannot break by accident.- Validation failures are results, not exceptions, keeping the "always returns a result" guarantee. Genuine programming errors still raise.
- A flow rebuilds its context at each step rather than mutating it, so one step can never quietly change data another step already read.
- Services and queries share one input engine,
Serviced::Typed. Include it in any plain object to get the same coerced, immutable, validatable attributes.
Contributing
Bug reports and pull requests are welcome. To hack on the gem:
bin/setup # install dependencies
bundle exec rake # run the specs and RuboCop
License
Released under the MIT License.