Llull
Composable Result types for Ruby. Implements functional programming patterns inspired by railway-oriented programming. Named after Ramon Llull, whose ars combinatoria explored the systematic combination of concepts.
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. }
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.)
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) { ||
flash.now[:error] =
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: "[email protected]", name: "Alice" },
{ email: "[email protected]", name: "Bob" },
{ email: "[email protected]", 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.)
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 statusvalue/error_type/error_data- Access result datamap { |value| ... }- Transform successful valuesand_then { |value| ... }- Chain Result-returning operationsor_else { |type, data| ... }- Recover with Result-returning operationrecover { |type, data| ... }- Convert failure to success valueunwrap!- Extract value (raises on failure)unwrap_or(default)- Extract value with defaultvalue_or { |type, data| ... }- Extract value with dynamic defaulttap_success { |value| ... }- Side effect on successtap_failure { |type, data| ... }- Side effect on failureon_success { |value| ... }- Rails-friendly success handleron_failure(type = nil) { |type, data| ... }- Rails-friendly failure handler
Module Methods
Success(value)- Create successful ResultFailure(type, data = nil)- Create failed Resultcollect(results)- Combine multiple Resultstraverse(collection) { |item| ... }- Map collection to Results and collectsequence(result)- Convert Result containing array to array of Resultspartition(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.