Most Ruby developers stick to object-oriented patterns, but I’ve found that mixing in functional programming techniques can make your code more maintainable, testable, and honestly, more fun to write.
After using functional patterns in production Ruby apps for the past few years, I want to share the techniques that actually work in real projects. We’ll build practical examples you can use immediately, and I’ll show you when functional approaches outperform traditional OOP solutions.
Why Functional Programming in Ruby?
Before jumping into code, let me address the elephant in the room: Ruby isn’t Haskell or Clojure. But that’s exactly why functional Ruby is powerful – you get to choose the best paradigm for each problem.
Here’s a concrete example. Compare these two approaches to data processing:
# Traditional OOP approach
class OrderProcessor
def initialize(orders)
@orders = orders
@processed = []
@failed = []
end
def process!
@orders.each do |order|
if valid_order?(order)
processed_order = apply_discounts(order)
processed_order = calculate_tax(processed_order)
@processed << processed_order
else
@failed << order
end
end
{ processed: @processed, failed: @failed }
end
private
def valid_order?(order)
order[:amount] > 0 && order[:customer_id]
end
def apply_discounts(order)
# Complex discount logic
order.merge(discounted_amount: order[:amount] * 0.9)
end
def calculate_tax(order)
order.merge(tax: order[:discounted_amount] * 0.08)
end
end
# Functional approach
module OrderProcessing
extend self
def process_orders(orders)
valid, invalid = orders.partition(&method(:valid_order?))
processed = valid
.map(&method(:apply_discounts))
.map(&method(:calculate_tax))
{ processed: processed, failed: invalid }
end
private
def valid_order?(order)
order[:amount] > 0 && order[:customer_id]
end
def apply_discounts(order)
order.merge(discounted_amount: order[:amount] * 0.9)
end
def calculate_tax(order)
order.merge(tax: order[:discounted_amount] * 0.08)
end
end
# Usage comparison
orders = [
{ amount: 100, customer_id: 1 },
{ amount: 0, customer_id: 2 }, # Invalid
{ amount: 200, customer_id: 3 }
]
# OOP style
processor = OrderProcessor.new(orders)
result1 = processor.process!
# Functional style
result2 = OrderProcessing.process_orders(orders)
puts result1 == result2 # => true
The functional version is more predictable (no state mutations), easier to test (pure functions), and more composable. Let’s dive deeper into the techniques that make this possible.
Advanced Closure Patterns
Closures in Ruby aren’t just anonymous functions – they’re powerful tools for creating reusable, configurable behavior. Here are patterns I use regularly in production:
Configuration Closures
class APIClient
def initialize(&config_block)
@config = Config.new
@config.instance_eval(&config_block) if config_block
end
def make_request(endpoint)
# Use @config to customize behavior
headers = @config.build_headers
# ... request logic
end
private
class Config
attr_accessor :api_key, :timeout, :retries
def initialize
@api_key = nil
@timeout = 30
@retries = 3
@custom_headers = {}
end
def header(key, value)
@custom_headers[key] = value
end
def build_headers
base_headers = { 'User-Agent' => 'MyApp/1.0' }
base_headers['Authorization'] = "Bearer #{@api_key}" if @api_key
base_headers.merge(@custom_headers)
end
end
end
# Usage with configuration closure
client = APIClient.new do
self.api_key = ENV['API_KEY']
self.timeout = 60
header 'X-Custom-Header', 'custom-value'
end
# This pattern is much cleaner than passing a giant hash
Function Factories for Business Logic
module PricingRules
extend self
def percentage_discount(percentage)
->(price) { price * (1 - percentage / 100.0) }
end
def fixed_discount(amount)
->(price) { [price - amount, 0].max }
end
def bulk_discount(min_quantity, discount_percentage)
->(price, quantity = 1) do
if quantity >= min_quantity
price * quantity * (1 - discount_percentage / 100.0)
else
price * quantity
end
end
end
def loyalty_multiplier(customer_tier)
multipliers = { bronze: 0.05, silver: 0.10, gold: 0.15 }
discount = multipliers[customer_tier] || 0
->(price) { price * (1 - discount) }
end
end
# Creating a pricing engine
class PricingEngine
def initialize
@rules = []
end
def add_rule(&rule_block)
@rules << rule_block
self
end
def calculate(base_price, **options)
@rules.reduce(base_price) do |current_price, rule|
rule.call(current_price, options)
end
end
end
# Usage: Build pricing rules dynamically
engine = PricingEngine.new
.add_rule(&PricingRules.percentage_discount(10)) # 10% off
.add_rule(&PricingRules.loyalty_multiplier(:gold)) # Gold customer discount
final_price = engine.calculate(100.0, quantity: 2)
puts final_price # Applies both discounts
Memoization with Closures
def memoize(&computation)
cache = {}
->(arg) do
cache.fetch(arg) do
result = computation.call(arg)
cache[arg] = result
result
end
end
end
# Example: Expensive computation
fibonacci = memoize do |n|
puts "Computing fibonacci(#{n})" # You'll see this only once per n
n <= 1 ? n : fibonacci.call(n - 1) + fibonacci.call(n - 2)
end
puts fibonacci.call(10) # => 55
puts fibonacci.call(10) # => 55 (cached, no computation output)
# Real-world example: API response caching
cached_user_lookup = memoize do |user_id|
# Expensive API call
puts "Fetching user #{user_id} from API"
{ id: user_id, name: "User #{user_id}", email: "user#{user_id}@example.com" }
end
user1 = cached_user_lookup.call(123) # API call made
user2 = cached_user_lookup.call(123) # Cached result returned
Functional Data Processing Pipelines
Ruby's Enumerable methods are perfect for building data processing pipelines. Here's how to use them effectively in real applications:
Log Analysis Pipeline
class LogAnalyzer
def self.analyze_logs(log_lines)
log_lines
.lazy # Important for large files
.map(&method(:parse_log_line))
.reject(&method(:system_log?))
.filter_map(&method(:extract_error))
.group_by { |error| error[:error_type] }
.transform_values(&method(:summarize_errors))
end
private
def self.parse_log_line(line)
# Parse log format: "2024-01-15 14:30:22 [ERROR] User authentication failed for user_id=123"
if match = line.match(/(\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}) \[(\w+)\] (.+)/)
{
timestamp: Time.parse(match[1]),
level: match[2],
message: match[3]
}
end
end
def self.system_log?(log_entry)
return true unless log_entry
log_entry[:message].include?('system') || log_entry[:level] == 'DEBUG'
end
def self.extract_error(log_entry)
return nil unless log_entry[:level] == 'ERROR'
# Extract different error types
case log_entry[:message]
when /authentication failed/i
{ error_type: 'auth_failure', timestamp: log_entry[:timestamp], details: log_entry[:message] }
when /database/i
{ error_type: 'database_error', timestamp: log_entry[:timestamp], details: log_entry[:message] }
when /timeout/i
{ error_type: 'timeout', timestamp: log_entry[:timestamp], details: log_entry[:message] }
else
{ error_type: 'other', timestamp: log_entry[:timestamp], details: log_entry[:message] }
end
end
def self.summarize_errors(errors)
{
count: errors.size,
first_occurrence: errors.map { |e| e[:timestamp] }.min,
last_occurrence: errors.map { |e| e[:timestamp] }.max,
sample_messages: errors.first(3).map { |e| e[:details] }
}
end
end
# Usage
log_data = [
"2024-01-15 14:30:22 [ERROR] User authentication failed for user_id=123",
"2024-01-15 14:30:23 [DEBUG] System checkpoint created",
"2024-01-15 14:30:24 [ERROR] Database connection timeout",
"2024-01-15 14:30:25 [ERROR] User authentication failed for user_id=456",
"2024-01-15 14:30:26 [INFO] User logged in successfully"
]
analysis = LogAnalyzer.analyze_logs(log_data)
puts analysis
# => {
# "auth_failure" => { count: 2, first_occurrence: ..., last_occurrence: ..., sample_messages: [...] },
# "timeout" => { count: 1, first_occurrence: ..., last_occurrence: ..., sample_messages: [...] }
# }
E-commerce Data Pipeline
module SalesAnalytics
extend self
def analyze_sales_data(orders)
{
revenue_by_category: revenue_by_category(orders),
top_customers: top_customers(orders),
sales_trends: sales_trends(orders),
product_performance: product_performance(orders)
}
end
private
def revenue_by_category(orders)
orders
.flat_map { |order| order[:items] }
.group_by { |item| item[:category] }
.transform_values { |items| items.sum { |item| item[:price] * item[:quantity] } }
.sort_by { |_, revenue| -revenue }
.to_h
end
def top_customers(orders, limit = 10)
orders
.group_by { |order| order[:customer_id] }
.transform_values { |customer_orders|
{
total_spent: customer_orders.sum { |order| order[:total] },
order_count: customer_orders.size,
avg_order_value: customer_orders.sum { |order| order[:total] } / customer_orders.size.to_f
}
}
.sort_by { |_, stats| -stats[:total_spent] }
.first(limit)
.to_h
end
def sales_trends(orders)
orders
.group_by { |order| order[:date].strftime('%Y-%m') }
.transform_values { |monthly_orders|
{
total_revenue: monthly_orders.sum { |order| order[:total] },
order_count: monthly_orders.size,
avg_order_value: monthly_orders.sum { |order| order[:total] } / monthly_orders.size.to_f
}
}
.sort_by { |month, _| month }
.to_h
end
def product_performance(orders)
orders
.flat_map { |order| order[:items] }
.group_by { |item| item[:product_id] }
.transform_values { |product_items|
total_quantity = product_items.sum { |item| item[:quantity] }
total_revenue = product_items.sum { |item| item[:price] * item[:quantity] }
{
units_sold: total_quantity,
revenue: total_revenue,
avg_price: total_revenue / total_quantity.to_f
}
}
.sort_by { |_, stats| -stats[:revenue] }
.to_h
end
end
# Sample data
orders = [
{
id: 1,
customer_id: 101,
date: Date.parse('2024-01-15'),
total: 150.00,
items: [
{ product_id: 'A1', category: 'Electronics', price: 100.00, quantity: 1 },
{ product_id: 'B1', category: 'Books', price: 50.00, quantity: 1 }
]
},
{
id: 2,
customer_id: 102,
date: Date.parse('2024-01-16'),
total: 200.00,
items: [
{ product_id: 'A2', category: 'Electronics', price: 200.00, quantity: 1 }
]
}
]
analysis = SalesAnalytics.analyze_sales_data(orders)
puts "Revenue by category: #{analysis[:revenue_by_category]}"
puts "Top customers: #{analysis[:top_customers]}"
Implementing Practical Monads
Monads sound scary, but they're just a pattern for handling computations that might fail. Here's a practical implementation I use for error handling:
Result Monad for Error Handling
class Result
def self.success(value)
Success.new(value)
end
def self.failure(error)
Failure.new(error)
end
def self.from_exception
begin
yield
rescue StandardError => e
failure(e.message)
end
end
class Success < Result
attr_reader :value
def initialize(value)
@value = value
end
def success?
true
end
def failure?
false
end
def bind(&block)
block.call(@value)
end
def map(&block)
Result.success(block.call(@value))
end
def or_else(_)
self
end
def to_s
"Success(#{@value})"
end
end
class Failure < Result
attr_reader :error
def initialize(error)
@error = error
end
def success?
false
end
def failure?
true
end
def bind(&block)
self
end
def map(&block)
self
end
def or_else(default_value)
Result.success(default_value)
end
def to_s
"Failure(#{@error})"
end
end
end
# Real-world example: API client with error handling
class UserService
def self.fetch_user(user_id)
validate_user_id(user_id)
.bind { |id| fetch_from_api(id) }
.bind { |user_data| parse_user_data(user_data) }
.bind { |user| enrich_user_data(user) }
end
private
def self.validate_user_id(user_id)
if user_id && user_id.to_s.match?(/^\d+$/)
Result.success(user_id.to_i)
else
Result.failure("Invalid user ID: #{user_id}")
end
end
def self.fetch_from_api(user_id)
Result.from_exception do
# Simulate API call
if user_id == 999
raise StandardError, "User not found"
end
{
id: user_id,
name: "User #{user_id}",
email: "user#{user_id}@example.com"
}
end
end
def self.parse_user_data(raw_data)
if raw_data[:name] && raw_data[:email]
Result.success(raw_data)
else
Result.failure("Incomplete user data")
end
end
def self.enrich_user_data(user)
# Add computed fields
enriched_user = user.merge(
display_name: user[:name].upcase,
email_domain: user[:email].split('@').last
)
Result.success(enriched_user)
end
end
# Usage
result = UserService.fetch_user(123)
if result.success?
puts "User: #{result.value}"
else
puts "Error: #{result.error}"
end
# Chain operations safely
user_names = [123, 999, "invalid", 456]
.map { |id| UserService.fetch_user(id) }
.filter_map { |result| result.success? ? result.value[:name] : nil }
puts user_names # => ["User 123", "User 456"]
Maybe Monad for Null Safety
class Maybe
def self.some(value)
value.nil? ? None.new : Some.new(value)
end
def self.none
None.new
end
class Some < Maybe
attr_reader :value
def initialize(value)
@value = value
end
def some?
true
end
def none?
false
end
def bind(&block)
result = block.call(@value)
result.is_a?(Maybe) ? result : Maybe.some(result)
end
def map(&block)
Maybe.some(block.call(@value))
end
def or_else(_)
self
end
def to_s
"Some(#{@value})"
end
end
class None < Maybe
def some?
false
end
def none?
true
end
def bind(&block)
self
end
def map(&block)
self
end
def or_else(default)
Maybe.some(default)
end
def to_s
"None"
end
end
end
# Example: Safe nested property access
class UserProfileService
def self.get_user_country(user_data)
Maybe.some(user_data)
.bind { |user| Maybe.some(user[:profile]) }
.bind { |profile| Maybe.some(profile[:address]) }
.bind { |address| Maybe.some(address[:country]) }
.or_else("Unknown")
.value
end
end
# Usage
user1 = {
id: 1,
profile: {
address: {
country: "USA"
}
}
}
user2 = { id: 2 } # Incomplete data
puts UserProfileService.get_user_country(user1) # => "USA"
puts UserProfileService.get_user_country(user2) # => "Unknown"
Immutable Data Structures in Practice
While Ruby doesn't have built-in immutable structures, we can create them and use existing gems effectively:
Simple Immutable Record
class ImmutableRecord
def self.define(*attributes)
Class.new(self) do
attributes.each do |attr|
define_method(attr) { @data[attr] }
end
define_method(:initialize) do |**kwargs|
@data = attributes.map { |attr| [attr, kwargs[attr]] }.to_h.freeze
freeze
end
define_method(:with) do |**changes|
self.class.new(**@data.merge(changes))
end
define_method(:to_h) { @data.dup }
define_method(:==) { |other| other.is_a?(self.class) && @data == other.instance_variable_get(:@data) }
define_method(:hash) { @data.hash }
end
end
end
# Usage
User = ImmutableRecord.define(:id, :name, :email, :age)
user = User.new(id: 1, name: "John", email: "john@example.com", age: 25)
updated_user = user.with(age: 26, email: "john.doe@example.com")
puts user.age # => 25
puts updated_user.age # => 26
puts user == updated_user # => false
# Useful for maintaining history
user_history = [user]
user = user.with(age: 26)
user_history << user
user = user.with(name: "John Doe")
user_history << user
puts "User history: #{user_history.map(&:to_h)}"
Immutable State Management
class StateManager
def initialize(initial_state = {})
@state = initial_state.freeze
@listeners = []
end
def state
@state
end
def update(&block)
new_state = block.call(@state.dup).freeze
old_state = @state
@state = new_state
notify_listeners(old_state, new_state)
new_state
end
def subscribe(&listener)
@listeners << listener
end
private
def notify_listeners(old_state, new_state)
@listeners.each { |listener| listener.call(old_state, new_state) }
end
end
# Example: Shopping cart state management
cart_manager = StateManager.new({
items: [],
total: 0,
discount: 0
})
# Subscribe to state changes
cart_manager.subscribe do |old_state, new_state|
puts "Cart updated: #{old_state[:total]} -> #{new_state[:total]}"
end
# Add item
cart_manager.update do |state|
new_item = { id: 1, name: "Widget", price: 29.99, quantity: 1 }
items = state[:items] + [new_item]
total = items.sum { |item| item[:price] * item[:quantity] }
state.merge(items: items, total: total)
end
# Apply discount
cart_manager.update do |state|
discount = state[:total] * 0.1
state.merge(discount: discount, total: state[:total] - discount)
end
puts "Final cart: #{cart_manager.state}"
Performance Comparison: Functional vs OOP
Let's benchmark functional vs object-oriented approaches for a common use case:
require 'benchmark'
# Test data
orders = 10_000.times.map do |i|
{
id: i,
amount: rand(100..1000),
items: rand(1..5).times.map do |j|
{ product_id: "P#{j}", price: rand(10..100), quantity: rand(1..3) }
end
}
end
# Object-oriented approach
class OrderProcessorOOP
def initialize(orders)
@orders = orders
end
def process
@orders.map do |order|
total = calculate_total(order)
tax = calculate_tax(total)
shipping = calculate_shipping(order)
{
id: order[:id],
subtotal: total,
tax: tax,
shipping: shipping,
total: total + tax + shipping
}
end
end
private
def calculate_total(order)
order[:items].sum { |item| item[:price] * item[:quantity] }
end
def calculate_tax(amount)
amount * 0.08
end
def calculate_shipping(order)
order[:items].size > 3 ? 0 : 9.99
end
end
# Functional approach
module OrderProcessorFP
extend self
def process(orders)
orders.map(&method(:process_order))
end
private
def process_order(order)
total = calculate_total(order[:items])
tax = calculate_tax(total)
shipping = calculate_shipping(order[:items])
{
id: order[:id],
subtotal: total,
tax: tax,
shipping: shipping,
total: total + tax + shipping
}
end
def calculate_total(items)
items.sum { |item| item[:price] * item[:quantity] }
end
def calculate_tax(amount)
amount * 0.08
end
def calculate_shipping(items)
items.size > 3 ? 0 : 9.99
end
end
# Benchmark
Benchmark.bm(20) do |x|
x.report("OOP approach:") do
processor = OrderProcessorOOP.new(orders)
processor.process
end
x.report("Functional approach:") do
OrderProcessorFP.process(orders)
end
x.report("Functional + lazy:") do
orders.lazy
.map { |order|
total = order[:items].sum { |item| item[:price] * item[:quantity] }
tax = total * 0.08
shipping = order[:items].size > 3 ? 0 : 9.99
{
id: order[:id],
subtotal: total,
tax: tax,
shipping: shipping,
total: total + tax + shipping
}
}
.force # Convert lazy enumerator to array
end
end
# Results on my machine (your results may vary):
# user system total real
# OOP approach: 0.045000 0.000000 0.045000 ( 0.044532)
# Functional approach: 0.041000 0.000000 0.041000 ( 0.040123)
# Functional + lazy: 0.039000 0.000000 0.039000 ( 0.038891)
When to Use Functional Patterns
Based on my experience, functional patterns work best for:
Data Transformation Pipelines: Log processing, ETL operations, report generation
Configuration and Setup: DSLs, API clients, service configuration
Error Handling: Operations that can fail at multiple steps
Mathematical Computations: Financial calculations, analytics, algorithms
Event Processing: Stream processing, reactive systems
Avoid functional patterns for:
• Heavy I/O operations where object state helps manage connections
• UI components that need to maintain complex internal state
• Performance-critical code where object allocation overhead matters
• Team environments where functional concepts are unfamiliar
Functional Testing Patterns
Functional code is often easier to test because of its predictability:
RSpec.describe OrderProcessorFP do
describe '.process' do
let(:orders) do
[
{
id: 1,
items: [
{ price: 10, quantity: 2 },
{ price: 15, quantity: 1 }
]
}
]
end
it 'processes orders correctly' do
result = OrderProcessorFP.process(orders)
expect(result.first).to include(
id: 1,
subtotal: 35,
tax: 2.8,
shipping: 9.99,
total: 47.79
)
end
# Easy to test edge cases
it 'applies free shipping for large orders' do
large_order = {
id: 2,
items: 5.times.map { { price: 10, quantity: 1 } }
}
result = OrderProcessorFP.process([large_order])
expect(result.first[:shipping]).to eq(0)
end
end
# Test individual functions in isolation
describe '.calculate_tax' do
it 'calculates 8% tax' do
expect(OrderProcessorFP.send(:calculate_tax, 100)).to eq(8.0)
end
end
end
The Bottom Line
Functional programming in Ruby isn't about abandoning objects – it's about choosing the right tool for each job. The techniques I've shown you work because they solve real problems:
• Closures create reusable, configurable behavior without complex inheritance
• Data pipelines make complex transformations readable and maintainable
• Monads handle errors gracefully without nested if statements
• Immutable data prevents bugs and makes state changes predictable
Start small: try using more Enumerable methods in your data processing. Add some closures for configuration. Experiment with the Result monad for error handling. You don't need to rewrite your entire application – functional patterns work best when mixed thoughtfully with Ruby's object-oriented strengths.
The goal isn't pure functional programming – it's writing better Ruby code that's easier to test, debug, and maintain.