Project

mojones

0.0
The project is in a healthy, maintained state
A lightweight framework for Ruby service objects using Dry::Monads with enforced result types and matching.
2005
2006
2007
2008
2009
2010
2011
2012
2013
2014
2015
2016
2017
2018
2019
2020
2021
2022
2023
2024
2025
2026
 Dependencies

Runtime

 Project Readme

Mojones

Service objects with monadic result handling. A lightweight framework around dry-monads that gives every service object one enforced shape — .call always returns a Success/ Failure result (or raises), and a small block-based matcher lets callers branch on the outcome without a pile of if result.success? checks.

Installation

bundle add mojones

Or add it to your Gemfile directly:

gem "mojones"

Usage

Inherit from Mojones::Base and return a Dry::Monads::Result from #call:

class CreateUser < Mojones::Base
  def initialize(params)
    @params = params
  end

  def call
    user = User.new(@params)
    return Failure(user.errors) unless user.save

    Success(user)
  end
end

Calling .call without a block gives you the Result itself:

result = CreateUser.call(params) # => Success(#<User ...>) or Failure(#<ActiveModel::Errors ...>)
result.value!                    # => #<User ...>, or raises on failure

Pass a block to match on the outcome instead:

CreateUser.call(params) do |m|
  m.success { |user| redirect_to user }
  m.failure { |errors| render :new, status: :unprocessable_entity }
end

success and failure can also filter on the value or error, using === (so classes, ranges, regexps and literal values all work):

notice = ChargeCard.call(order) do |m|
  m.success { |charge| "Charged #{charge.amount}" }
  m.failure(CardDeclined) { |_| "Your card was declined" }
  m.failure { |_error, message| message }
end

Handlers are tried in order and the first match wins, like case/when. Later handlers don't run at all, so put specific handlers before catch-alls, and side effects like redirect_to only ever happen once.

If the service raises, the exception is matchable as a failure, so m.failure(ActiveRecord::RecordNotFound) { ... } works. Without a block, or when no handler matches, the exception is re-raised.

If #call returns something that isn't a Dry::Monads::Result, .call raises Mojones::Base::Errors::ServiceReturnedNonResult. That's a bug in the service, so it can't be matched.

Failure values and I18n

A one-argument failure block gets the failure value translated:

  • A Symbol is looked up under the service's I18n scope, so Failure(:card_declined) in Billing::ChargeCard is looked up as billing.charge_card.card_declined. If there's no translation, it falls back to "Card declined".
  • An exception is looked up by its class name, falling back to its message.
  • Any other value (for example user.errors) is passed through unchanged.

Translation needs I18n and ActiveSupport, so in practice it means Rails. Without them, Symbols and exceptions arrive as to_s strings. Anonymous classes are never translated.

To get the untranslated value as well, take two arguments:

m.failure { |error, message| flash[:alert] = message }

Rails generator

bin/rails generate mojones:service CreateUser

Scaffolds app/services/create_user.rb with the Mojones::Base skeleton above.

Development

bundle install
bundle exec rspec    # tests
bundle exec rubocop  # lint

CI (.github/workflows/) runs both on every push and pull request.

License

MIT — see LICENSE.txt.


Built by TrueFlux.