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
endWith 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) }
endInstallation
Add to your Gemfile:
gem 'llull'Then run:
bundle installUsage
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
endBasic 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
endController 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
endPattern 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"
endAdvanced 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) }
endSide 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.