Project

llull

0.0
The project is in a healthy, maintained state
Inspired by railway-oriented programming, Llull provides chainable operations that make complex business logic clear and maintainable.
2005
2006
2007
2008
2009
2010
2011
2012
2013
2014
2015
2016
2017
2018
2019
2020
2021
2022
2023
2024
2025
 Dependencies

Development

~> 13.0
~> 3.0
~> 1.21
 Project Readme

Llull

Composable Result types for Ruby. Implements functional programming patterns inspired by railway-oriented programming. Named after Ramon Llull.

Overview

Replace nested conditionals and exception handling:

def create_order(params)
  return { error: "Invalid params" } unless valid_params?(params)

  user = User.find(params[:user_id])
  return { error: "User not found" } unless user

  begin
    order = Order.create!(params.merge(user: user))
    { success: true, order: order }
  rescue ActiveRecord::RecordInvalid => e
    { error: e.message }
  end
end

With composable operations:

def create_order(params)
  validate_params(params)
    .and_then { |valid_params| find_user(valid_params) }
    .and_then { |user, params| create_order_record(user, params) }
    .tap_success { |order| send_confirmation(order) }
end

Installation

Add to your Gemfile:

gem 'llull'

Then run:

bundle install

Usage

Include Llull in your classes to access the Success() and Failure() constructors:

class MyService
  include Llull

  def call(input)
    Success(input)
      .and_then { |data| process_data(data) }
      .map { |result| format_result(result) }
  end
end

Basic Result Operations

Creating Results

# Success with a value
success = Success("hello world")
success.success? # => true
success.value    # => "hello world"

# Failure with error type and data
failure = Failure(:validation, "Email is required")
failure.failure?    # => true
failure.error_type  # => :validation
failure.error_data  # => "Email is required"

Transforming Values

# map: transform successful values
Success(10).map { |x| x * 2 }  # => Success(20)
Failure(:error, "oops").map { |x| x * 2 }  # => Failure(:error, "oops")

# and_then: chain operations that return Results
Success("hello")
  .and_then { |str| Success(str.upcase) }
  .and_then { |str| Success("#{str}!") }
# => Success("HELLO!")

Error Recovery

# or_else: recover with another Result-returning operation
result = Failure(:primary_failed, "error")
  .or_else { |type, data| Success("backup plan") }
# => Success("backup plan")

# recover: convert failure to success value
result = Failure(:not_found, "User missing")
  .recover { |type, data| "Default User" }
# => Success("Default User")

Unwrapping Values

# Unsafe unwrapping (raises on failure)
Success("hello").unwrap!  # => "hello"
Failure(:error, "oops").unwrap!  # => raises Llull::ResultError

# Safe unwrapping with default
Success("hello").unwrap_or("default")     # => "hello"
Failure(:error, "oops").unwrap_or("default")  # => "default"

# Dynamic default based on error
Failure(:not_found, "User").value_or { |type, data| "Missing #{data}" }
# => "Missing User"

Rails Service Objects

Complete example:

class UserRegistrationService
  include Llull

  def call(params)
    validate_params(params)
      .and_then { |valid_params| check_email_uniqueness(valid_params) }
      .and_then { |valid_params| create_user(valid_params) }
      .tap_success { |user| send_welcome_email(user) }
  end

  private

  def validate_params(params)
    errors = []
    errors << "Email is required" if params[:email].blank?
    errors << "Invalid email format" unless params[:email]&.include?("@")
    errors << "Password too short" if params[:password]&.length.to_i < 8

    errors.empty? ? Success(params) : Failure(:validation, errors)
  end

  def check_email_uniqueness(params)
    existing = User.find_by(email: params[:email])
    existing ? Failure(:duplicate_email, "Email already taken") : Success(params)
  end

  def create_user(params)
    user = User.new(params)
    user.save ? Success(user) : Failure(:creation_failed, user.errors.full_messages)
  end

  def send_welcome_email(user)
    WelcomeMailer.deliver_later(user)
  end
end

Controller Integration

Controller usage:

class UsersController < ApplicationController
  def create
    UserRegistrationService.new.call(user_params)
      .on_success { |user| redirect_to user, notice: "Welcome!" }
      .on_failure(:validation) { |errors|
        flash.now[:errors] = errors
        render :new, status: :unprocessable_entity
      }
      .on_failure(:duplicate_email) { |message|
        flash.now[:error] = message
        render :new, status: :conflict
      }
      .on_failure { |type, data|
        flash[:error] = "Registration failed"
        redirect_to signup_path
      }
  end
end

Pattern Matching (Ruby 3+)

Ruby 3+ pattern matching support:

result = UserService.new.call(params)

case result
in [:success, user]
  puts "Created user: #{user.email}"
in [:failure, :validation, errors]
  puts "Validation failed: #{errors.join(', ')}"
in [:failure, error_type, data]
  puts "Other error: #{error_type}"
end

# Or with hash patterns
case result
in { success: true, value: user }
  puts "Success: #{user.email}"
in { success: false, error_type: :validation }
  puts "Validation error"
end

Advanced Pattern Matching DSL

Complex error handling:

PaymentService.new.call(order, payment_method).match do |m|
  m.success { |payment| redirect_to_confirmation(payment) }
  m.failure(:insufficient_funds) { |data| offer_payment_plan }
  m.failure(:invalid_card) { |data| ask_for_different_card }
  m.failure(:service_unavailable) { |data| try_again_later }
  m.failure { |type, data| log_unexpected_error(type, data) }
end

Side Effects

Execute side effects without affecting the Result chain:

CreateOrderService.new.call(params)
  .tap_success { |order| analytics.track("order_created", order.id) }
  .tap_failure { |type, data| logger.error("Order failed: #{type}") }
  .and_then { |order| PaymentService.new.call(order) }
  .tap_success { |payment| send_receipt(payment) }

Collection Operations

Multiple Results:

# Process multiple items, fail fast on first error
user_data = [
  { email: "user1@example.com", name: "Alice" },
  { email: "user2@example.com", name: "Bob" },
  { email: "user3@example.com", name: "Carol" }
]

results = user_data.map { |data| UserService.new.call(data) }
all_users = collect(results)  # Success([user1, user2, user3]) or first Failure

# Transform a collection with Results
result = traverse(user_emails) do |email|
  user = User.find_by(email: email)
  user ? Success(user) : Failure(:not_found, email)
end
# => Success([user1, user2, ...]) or first Failure

# Convert Result containing array into array of Results
result = Success([1, 2, 3])
individual_results = sequence(result)
# => [Success(1), Success(2), Success(3)]

failure_result = Failure(:error, "failed")
sequence(failure_result)
# => [Failure(:error, "failed")]

# Partition mixed Results into successes and failures
mixed_results = [
  Success("good1"),
  Failure(:error, "bad1"),
  Success("good2")
]
successes, failures = partition(mixed_results)
# successes => ["good1", "good2"]
# failures => [[:error, "bad1"]]

Error Recovery

Multiple fallback strategies:

PrimaryPaymentService.new.call(order)
  .or_else { |type, data| BackupPaymentService.new.call(order) }
  .or_else { |type, data| ManualReviewService.new.call(order) }
  .recover { |type, data|
    # Last resort: create pending order
    order.update!(status: 'pending_payment')
    order
  }

Integration with ActiveRecord

class Order
  def self.create_with_validation(params)
    order = new(params)

    if order.valid?
      order.save ? Llull::Success(order) : Llull::Failure(:save_failed, order.errors)
    else
      Llull::Failure(:validation, order.errors.full_messages)
    end
  end
end

# Usage
Order.create_with_validation(params)
  .and_then { |order| PaymentService.new.call(order) }
  .and_then { |payment| ShippingService.new.call(payment.order) }

API Reference

Result Methods

  • success? / failure? - Check result status
  • value / error_type / error_data - Access result data
  • map { |value| ... } - Transform successful values
  • and_then { |value| ... } - Chain Result-returning operations
  • or_else { |type, data| ... } - Recover with Result-returning operation
  • recover { |type, data| ... } - Convert failure to success value
  • unwrap! - Extract value (raises on failure)
  • unwrap_or(default) - Extract value with default
  • value_or { |type, data| ... } - Extract value with dynamic default
  • tap_success { |value| ... } - Side effect on success
  • tap_failure { |type, data| ... } - Side effect on failure
  • on_success { |value| ... } - Rails-friendly success handler
  • on_failure(type = nil) { |type, data| ... } - Rails-friendly failure handler

Module Methods

  • Success(value) - Create successful Result
  • Failure(type, data = nil) - Create failed Result
  • collect(results) - Combine multiple Results
  • traverse(collection) { |item| ... } - Map collection to Results and collect
  • sequence(result) - Convert Result containing array to array of Results
  • partition(results) - Split array of Results into [successes, failures]

Pattern Matching Support

Results work with Ruby 3+ pattern matching via deconstruct and deconstruct_keys.

Requirements

  • Ruby 3.2+
  • No external dependencies

Development

After checking out the repo, run bin/setup to install dependencies. Then, run rake spec to run the tests. You can also run bin/console for an interactive prompt that will allow you to experiment.

To install this gem onto your local machine, run bundle exec rake install. To release a new version, update the version number in version.rb, and then run bundle exec rake release, which will create a git tag for the version, push git commits and the created tag, and push the .gem file to rubygems.org.

Contributing

Bug reports and pull requests are welcome on GitHub at https://github.com/[USERNAME]/llull.

License

The gem is available as open source under the terms of the MIT License.